mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-09 07:28:44 +00:00
update
This commit is contained in:
1
src/assets/svg/video-settings-gear.svg
Normal file
1
src/assets/svg/video-settings-gear.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M24 13.616v-3.232c-1.651-.587-2.694-.752-3.219-2.019v-.001c-.527-1.271.1-2.134.847-3.707l-2.285-2.285c-1.561.742-2.433 1.375-3.707.847h-.001c-1.269-.526-1.435-1.576-2.019-3.219h-3.232c-.582 1.635-.749 2.692-2.019 3.219h-.001c-1.271.528-2.132-.098-3.707-.847l-2.285 2.285c.745 1.568 1.375 2.434.847 3.707-.527 1.271-1.584 1.438-3.219 2.02v3.232c1.632.58 2.692.749 3.219 2.019.53 1.282-.114 2.166-.847 3.707l2.285 2.286c1.562-.743 2.434-1.375 3.707-.847h.001c1.27.526 1.436 1.579 2.019 3.219h3.232c.582-1.636.75-2.69 2.027-3.222h.001c1.262-.524 2.12.101 3.698.851l2.285-2.286c-.744-1.563-1.375-2.433-.848-3.706.527-1.271 1.588-1.44 3.221-2.021zm-12 2.384c-2.209 0-4-1.791-4-4s1.791-4 4-4 4 1.791 4 4-1.791 4-4 4z"/></svg>
|
After Width: | Height: | Size: 811 B |
136
src/index.html
136
src/index.html
@@ -50,22 +50,33 @@
|
|||||||
<input type="text" id="title" required
|
<input type="text" id="title" required
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="magnet" class="block text-sm font-medium text-gray-700">Magnet Link</label>
|
<label for="magnet" class="block text-sm font-medium text-gray-700">Magnet Link</label>
|
||||||
<input type="text" id="magnet" required
|
<input type="text" id="magnet" required
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="thumbnail" class="block text-sm font-medium text-gray-700">Thumbnail URL (optional)</label>
|
<label for="thumbnail" class="block text-sm font-medium text-gray-700">Thumbnail URL (optional)</label>
|
||||||
<input type="url" id="thumbnail"
|
<input type="url" id="thumbnail"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description Field -->
|
<!-- Description Field -->
|
||||||
<div>
|
<div>
|
||||||
<label for="description" class="block text-sm font-medium text-gray-700">Description (optional)</label>
|
<label for="description" class="block text-sm font-medium text-gray-700">Description (optional)</label>
|
||||||
<textarea id="description" rows="3"
|
<textarea id="description" rows="3"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"></textarea>
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ADDED FOR PRIVATE LISTINGS -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input type="checkbox" id="isPrivate" class="form-checkbox h-5 w-5">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Private Listing (Encrypt Magnet)</span>
|
||||||
|
</div>
|
||||||
|
<!-- END ADDED FOR PRIVATE LISTINGS -->
|
||||||
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||||
Share Video
|
Share Video
|
||||||
@@ -90,79 +101,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video List -->
|
<!-- Video List -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div id="videoList" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
|
<div id="videoList" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||||
<!-- Videos will be dynamically inserted here -->
|
<!-- Videos will be dynamically inserted here -->
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Improved Video Player Modal -->
|
|
||||||
<div id="playerModal" class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4">
|
|
||||||
<div class="bg-gray-900 rounded-lg max-w-4xl w-full relative overflow-hidden">
|
|
||||||
<!-- Close button -->
|
|
||||||
<button id="closePlayer" class="absolute top-4 right-4 z-50 text-white bg-gray-800 hover:bg-gray-700 rounded-full p-2">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Video container -->
|
|
||||||
<div class="aspect-w-16 aspect-h-9">
|
|
||||||
<video id="modalVideo" controls class="w-full rounded-t-lg"></video>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Video info section -->
|
<!-- Improved Video Player Modal -->
|
||||||
<div class="p-6 text-white">
|
<div id="playerModal" class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4">
|
||||||
<!-- Video Title -->
|
<div class="bg-gray-900 rounded-lg max-w-4xl w-full relative overflow-hidden">
|
||||||
<h2 id="videoTitle" class="text-2xl font-bold mb-2"></h2>
|
<!-- Close button -->
|
||||||
|
<button id="closePlayer" class="absolute top-4 right-4 z-50 text-white bg-gray-800 hover:bg-gray-700 rounded-full p-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Video Timestamp -->
|
<!-- Video container -->
|
||||||
<div class="flex items-center justify-between text-sm text-gray-400 mb-4">
|
<div class="aspect-w-16 aspect-h-9">
|
||||||
<span id="videoTimestamp">just now</span>
|
<video id="modalVideo" controls class="w-full rounded-t-lg"></video>
|
||||||
<div id="modalStatus" class="text-gray-300">Initializing...</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Creator info -->
|
<!-- Video info section -->
|
||||||
<div class="flex items-center mb-4 p-4 bg-gray-800 rounded-lg">
|
<div class="p-6 text-white">
|
||||||
<div id="creatorAvatar" class="w-12 h-12 rounded-full bg-gray-700 overflow-hidden">
|
<!-- Video Title -->
|
||||||
<img src="" alt="Creator" class="w-full h-full object-cover">
|
<h2 id="videoTitle" class="text-2xl font-bold mb-2"></h2>
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 id="creatorName" class="font-medium text-lg">Creator Name</h3>
|
|
||||||
<p id="creatorNpub" class="text-sm text-gray-400">npub...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Video Description -->
|
<!-- Video Timestamp -->
|
||||||
<div class="bg-gray-800 rounded-lg p-4 mb-4">
|
<div class="flex items-center justify-between text-sm text-gray-400 mb-4">
|
||||||
<p id="videoDescription" class="text-gray-300 whitespace-pre-wrap">No description available.</p>
|
<span id="videoTimestamp">just now</span>
|
||||||
</div>
|
<div id="modalStatus" class="text-gray-300">Initializing...</div>
|
||||||
|
|
||||||
<!-- Torrent stats -->
|
|
||||||
<div class="bg-gray-800 rounded-lg p-4">
|
|
||||||
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
|
|
||||||
<div class="bg-blue-500 h-2 rounded-full" id="modalProgress" style="width: 0%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-sm text-gray-400">
|
|
||||||
<span id="modalPeers">Peers: 0</span>
|
<!-- Creator info -->
|
||||||
<span id="modalSpeed">Speed: 0 KB/s</span>
|
<div class="flex items-center mb-4 p-4 bg-gray-800 rounded-lg">
|
||||||
<span id="modalDownloaded">Downloaded: 0 MB / 0 MB</span>
|
<div id="creatorAvatar" class="w-12 h-12 rounded-full bg-gray-700 overflow-hidden">
|
||||||
|
<img src="" alt="Creator" class="w-full h-full object-cover">
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<h3 id="creatorName" class="font-medium text-lg">Creator Name</h3>
|
||||||
|
<p id="creatorNpub" class="text-sm text-gray-400">npub...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Description -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4 mb-4">
|
||||||
|
<p id="videoDescription" class="text-gray-300 whitespace-pre-wrap">No description available.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Torrent stats -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
|
||||||
|
<div class="bg-blue-500 h-2 rounded-full" id="modalProgress" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm text-gray-400">
|
||||||
|
<span id="modalPeers">Peers: 0</span>
|
||||||
|
<span id="modalSpeed">Speed: 0 KB/s</span>
|
||||||
|
<span id="modalDownloaded">Downloaded: 0 MB / 0 MB</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<!-- Load WebTorrent via CDN -->
|
<!-- Load WebTorrent via CDN -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/webtorrent/1.9.7/webtorrent.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/webtorrent/1.9.7/webtorrent.min.js"></script>
|
||||||
<!-- Load Nostr library -->
|
<!-- Load Nostr library -->
|
||||||
<script src="js/libs/nostr.bundle.js"></script>
|
<script src="js/libs/nostr.bundle.js"></script>
|
||||||
<!-- Load JavaScript Modules -->
|
<!-- Load JavaScript Modules -->
|
||||||
<script type="module" src="js/config.js"></script>
|
<script type="module" src="js/config.js"></script>
|
||||||
<script type="module" src="js/webtorrent.js"></script>
|
<script type="module" src="js/webtorrent.js"></script>
|
||||||
<script type="module" src="js/nostr.js"></script>
|
<script type="module" src="js/nostr.js"></script>
|
||||||
<script type="module" src="js/app.js"></script>
|
<script type="module" src="js/app.js"></script>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
362
src/js/app.js
362
src/js/app.js
@@ -54,6 +54,10 @@ class NosTubeApp {
|
|||||||
|
|
||||||
this.pubkey = null;
|
this.pubkey = null;
|
||||||
this.currentMagnetUri = null;
|
this.currentMagnetUri = null;
|
||||||
|
|
||||||
|
// ADDED FOR VERSIONING/PRIVATE/DELETE:
|
||||||
|
// If you created an <input type="checkbox" id="isPrivate" /> in your HTML form:
|
||||||
|
this.isPrivateCheckbox = document.getElementById('isPrivate');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,7 +166,6 @@ class NosTubeApp {
|
|||||||
|
|
||||||
// Detailed Modal Video Event Listeners
|
// Detailed Modal Video Event Listeners
|
||||||
if (this.modalVideo) {
|
if (this.modalVideo) {
|
||||||
// Add detailed video error logging
|
|
||||||
this.modalVideo.addEventListener('error', (e) => {
|
this.modalVideo.addEventListener('error', (e) => {
|
||||||
const error = e.target.error;
|
const error = e.target.error;
|
||||||
this.log('Modal video error:', error);
|
this.log('Modal video error:', error);
|
||||||
@@ -263,7 +266,7 @@ class NosTubeApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles video submission.
|
* Handles video submission (with version, private listing).
|
||||||
*/
|
*/
|
||||||
async handleSubmit(e) {
|
async handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -274,15 +277,23 @@ class NosTubeApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const descriptionElement = document.getElementById('description');
|
const descriptionElement = document.getElementById('description');
|
||||||
|
|
||||||
|
// ADDED FOR VERSIONING/PRIVATE/DELETE:
|
||||||
|
// If you have a checkbox with id="isPrivate" in HTML
|
||||||
|
const isPrivate = this.isPrivateCheckbox
|
||||||
|
? this.isPrivateCheckbox.checked
|
||||||
|
: false;
|
||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
title: document.getElementById('title') ? document.getElementById('title').value.trim() : '',
|
version: 2, // We set the version to 2 for new posts
|
||||||
magnet: document.getElementById('magnet') ? document.getElementById('magnet').value.trim() : '',
|
title: document.getElementById('title')?.value.trim() || '',
|
||||||
thumbnail: document.getElementById('thumbnail') ? document.getElementById('thumbnail').value.trim() : '',
|
magnet: document.getElementById('magnet')?.value.trim() || '',
|
||||||
description: descriptionElement ? descriptionElement.value.trim() : '',
|
thumbnail: document.getElementById('thumbnail')?.value.trim() || '',
|
||||||
mode: isDevMode ? 'dev' : 'live'
|
description: descriptionElement?.value.trim() || '',
|
||||||
|
mode: isDevMode ? 'dev' : 'live',
|
||||||
|
isPrivate // new field to handle private listings
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debugging Log: Check formData
|
|
||||||
this.log('Form Data Collected:', formData);
|
this.log('Form Data Collected:', formData);
|
||||||
|
|
||||||
if (!formData.title || !formData.magnet) {
|
if (!formData.title || !formData.magnet) {
|
||||||
@@ -293,6 +304,12 @@ class NosTubeApp {
|
|||||||
try {
|
try {
|
||||||
await nostrClient.publishVideo(formData, this.pubkey);
|
await nostrClient.publishVideo(formData, this.pubkey);
|
||||||
this.submitForm.reset();
|
this.submitForm.reset();
|
||||||
|
|
||||||
|
// If the private checkbox was checked, reset it
|
||||||
|
if (this.isPrivateCheckbox) {
|
||||||
|
this.isPrivateCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
await this.loadVideos();
|
await this.loadVideos();
|
||||||
this.showSuccess('Video shared successfully!');
|
this.showSuccess('Video shared successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -309,55 +326,65 @@ class NosTubeApp {
|
|||||||
const videos = await nostrClient.fetchVideos();
|
const videos = await nostrClient.fetchVideos();
|
||||||
this.log('Fetched videos (raw):', videos);
|
this.log('Fetched videos (raw):', videos);
|
||||||
|
|
||||||
// Log detailed type info
|
|
||||||
this.log('Videos type:', typeof videos);
|
|
||||||
this.log('Is Array:', Array.isArray(videos), 'Length:', videos?.length);
|
|
||||||
|
|
||||||
if (!videos) {
|
if (!videos) {
|
||||||
this.log('No videos received');
|
this.log('No videos received');
|
||||||
throw new Error('No videos received from relays');
|
throw new Error('No videos received from relays');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to array if it isn't one
|
// Convert to array if not already
|
||||||
const videosArray = Array.isArray(videos) ? videos : [videos];
|
const videosArray = Array.isArray(videos) ? videos : [videos];
|
||||||
|
|
||||||
this.log('Processing videos array:', JSON.stringify(videosArray, null, 2));
|
// **Filter** so we only show:
|
||||||
|
// - isPrivate === false (public videos)
|
||||||
|
// - or isPrivate === true but pubkey === this.pubkey
|
||||||
|
const displayedVideos = videosArray.filter(video => {
|
||||||
|
if (!video.isPrivate) {
|
||||||
|
// Public video => show it
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Else it's private; only show if it's owned by the logged-in user
|
||||||
|
return (this.pubkey && video.pubkey === this.pubkey);
|
||||||
|
});
|
||||||
|
|
||||||
if (videosArray.length === 0) {
|
if (displayedVideos.length === 0) {
|
||||||
this.log('No valid videos found.');
|
this.log('No valid videos found after filtering.');
|
||||||
this.videoList.innerHTML = '<p class="text-center text-gray-500">No videos available yet. Be the first to upload one!</p>';
|
this.videoList.innerHTML = `
|
||||||
|
<p class="text-center text-gray-500">
|
||||||
|
No public videos available yet. Be the first to upload one!
|
||||||
|
</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log each video object before rendering
|
this.log('Processing filtered videos:', displayedVideos);
|
||||||
videosArray.forEach((video, index) => {
|
|
||||||
|
displayedVideos.forEach((video, index) => {
|
||||||
this.log(`Video ${index} details:`, {
|
this.log(`Video ${index} details:`, {
|
||||||
id: video.id,
|
id: video.id,
|
||||||
title: video.title,
|
title: video.title,
|
||||||
magnet: video.magnet,
|
magnet: video.magnet,
|
||||||
mode: video.mode,
|
isPrivate: video.isPrivate,
|
||||||
pubkey: video.pubkey,
|
pubkey: video.pubkey,
|
||||||
created_at: video.created_at,
|
created_at: video.created_at
|
||||||
hasTitle: Boolean(video.title),
|
|
||||||
hasMagnet: Boolean(video.magnet),
|
|
||||||
hasMode: Boolean(video.mode)
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.renderVideoList(videosArray);
|
// Now render only the displayedVideos
|
||||||
this.log(`Rendered ${videosArray.length} videos successfully`);
|
this.renderVideoList(displayedVideos);
|
||||||
|
this.log(`Rendered ${displayedVideos.length} videos successfully`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log('Failed to fetch videos:', error);
|
this.log('Failed to fetch videos:', error);
|
||||||
this.log('Error details:', {
|
|
||||||
name: error.name,
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack
|
|
||||||
});
|
|
||||||
this.showError('An error occurred while loading videos. Please try again later.');
|
this.showError('An error occurred while loading videos. Please try again later.');
|
||||||
this.videoList.innerHTML = '<p class="text-center text-gray-500">No videos available at the moment. Please try again later.</p>';
|
this.videoList.innerHTML = `
|
||||||
|
<p class="text-center text-gray-500">
|
||||||
|
No videos available at the moment. Please try again later.
|
||||||
|
</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the given list of videos. If a video is private and belongs to the user,
|
||||||
|
* highlight with a special border (e.g. border-yellow-500).
|
||||||
|
*/
|
||||||
async renderVideoList(videos) {
|
async renderVideoList(videos) {
|
||||||
try {
|
try {
|
||||||
console.log('RENDER VIDEO LIST - Start', {
|
console.log('RENDER VIDEO LIST - Start', {
|
||||||
@@ -368,23 +395,22 @@ class NosTubeApp {
|
|||||||
|
|
||||||
if (!videos) {
|
if (!videos) {
|
||||||
console.error('NO VIDEOS RECEIVED');
|
console.error('NO VIDEOS RECEIVED');
|
||||||
this.videoList.innerHTML = '<p class="text-center text-gray-500">No videos found.</p>';
|
this.videoList.innerHTML = `<p class="text-center text-gray-500">No videos found.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure videos is an array
|
|
||||||
const videoArray = Array.isArray(videos) ? videos : [videos];
|
const videoArray = Array.isArray(videos) ? videos : [videos];
|
||||||
|
|
||||||
if (videoArray.length === 0) {
|
if (videoArray.length === 0) {
|
||||||
console.error('VIDEO ARRAY IS EMPTY');
|
console.error('VIDEO ARRAY IS EMPTY');
|
||||||
this.videoList.innerHTML = '<p class="text-center text-gray-500">No videos available.</p>';
|
this.videoList.innerHTML = `<p class="text-center text-gray-500">No videos available.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort videos by creation date (newest first)
|
// Sort by creation date
|
||||||
videoArray.sort((a, b) => b.created_at - a.created_at);
|
videoArray.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
// Fetch usernames and profile pictures for all pubkeys
|
// Prepare to fetch user profiles
|
||||||
const userProfiles = new Map();
|
const userProfiles = new Map();
|
||||||
const uniquePubkeys = [...new Set(videoArray.map(v => v.pubkey))];
|
const uniquePubkeys = [...new Set(videoArray.map(v => v.pubkey))];
|
||||||
|
|
||||||
@@ -417,6 +443,7 @@ class NosTubeApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build HTML for each video
|
||||||
const renderedVideos = videoArray.map((video, index) => {
|
const renderedVideos = videoArray.map((video, index) => {
|
||||||
try {
|
try {
|
||||||
if (!this.validateVideo(video, index)) {
|
if (!this.validateVideo(video, index)) {
|
||||||
@@ -430,54 +457,113 @@ class NosTubeApp {
|
|||||||
};
|
};
|
||||||
const timeAgo = this.formatTimeAgo(video.created_at);
|
const timeAgo = this.formatTimeAgo(video.created_at);
|
||||||
|
|
||||||
// Only show "Edit" button if this user owns the video (video.pubkey === this.pubkey)
|
// If user is the owner
|
||||||
const canEdit = (video.pubkey === this.pubkey);
|
const canEdit = (video.pubkey === this.pubkey);
|
||||||
const editButton = canEdit
|
|
||||||
? `<button
|
// If it's private + user owns it => highlight with a special border
|
||||||
class="mt-2 text-sm text-blue-400 hover:text-blue-300"
|
const highlightClass = (video.isPrivate && canEdit)
|
||||||
onclick="app.handleEditVideo(${index})">
|
? 'border-2 border-yellow-500'
|
||||||
Edit
|
: 'border-none'; // normal case
|
||||||
</button>`
|
|
||||||
: '';
|
// Gear menu (unchanged)
|
||||||
|
const gearMenu = canEdit
|
||||||
|
? `
|
||||||
|
<div class="relative inline-block ml-3 overflow-visible">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
onclick="document.getElementById('settingsDropdown-${index}').classList.toggle('hidden')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="assets/svg/video-settings-gear.svg"
|
||||||
|
alt="Settings"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<!-- The dropdown appears above the gear (bottom-full) -->
|
||||||
|
<div
|
||||||
|
id="settingsDropdown-${index}"
|
||||||
|
class="hidden absolute right-0 bottom-full mb-2 w-32 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||||
|
>
|
||||||
|
<div class="py-1">
|
||||||
|
<button
|
||||||
|
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||||
|
onclick="app.handleEditVideo(${index}); document.getElementById('settingsDropdown-${index}').classList.add('hidden');"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||||
|
onclick="app.handleDeleteVideo(${index}); document.getElementById('settingsDropdown-${index}').classList.add('hidden');"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300">
|
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
||||||
<div class="aspect-w-16 aspect-h-9 bg-gray-800 cursor-pointer relative group"
|
|
||||||
onclick="app.playVideo('${encodeURIComponent(video.magnet)}')">
|
<!-- VIDEO THUMBNAIL -->
|
||||||
${video.thumbnail ?
|
<div
|
||||||
`<img src="${this.escapeHTML(video.thumbnail)}"
|
class="aspect-w-16 aspect-h-9 bg-gray-800 cursor-pointer relative group"
|
||||||
alt="${this.escapeHTML(video.title)}"
|
onclick="app.playVideo('${encodeURIComponent(video.magnet)}')"
|
||||||
class="w-full h-full object-cover">` :
|
>
|
||||||
`<div class="flex items-center justify-center h-full bg-gray-800">
|
${
|
||||||
<svg class="w-16 h-16 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
video.thumbnail
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
? `<img
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
src="${this.escapeHTML(video.thumbnail)}"
|
||||||
</svg>
|
alt="${this.escapeHTML(video.title)}"
|
||||||
</div>`
|
class="w-full h-full object-cover"
|
||||||
}
|
>`
|
||||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300"></div>
|
: `<div class="flex items-center justify-center h-full bg-gray-800">
|
||||||
|
<svg class="w-16 h-16 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CARD INFO -->
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h3 class="text-lg font-bold text-white mb-2 line-clamp-2 hover:text-blue-400 cursor-pointer"
|
<!-- TITLE -->
|
||||||
onclick="app.playVideo('${encodeURIComponent(video.magnet)}')">
|
<h3
|
||||||
${this.escapeHTML(video.title)}
|
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
||||||
|
onclick="app.playVideo('${encodeURIComponent(video.magnet)}')"
|
||||||
|
>
|
||||||
|
${this.escapeHTML(video.title)}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex space-x-3 items-center mb-2">
|
|
||||||
<div class="flex-shrink-0">
|
<!-- CREATOR info + gear icon -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<!-- Left: Avatar & user/time -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden">
|
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden">
|
||||||
<img src="${this.escapeHTML(profile.picture)}" alt="${profile.name}" class="w-full h-full object-cover">
|
<img
|
||||||
</div>
|
src="${this.escapeHTML(profile.picture)}"
|
||||||
</div>
|
alt="${profile.name}"
|
||||||
<div class="flex-1 min-w-0">
|
class="w-full h-full object-cover"
|
||||||
<p class="text-sm text-gray-400 hover:text-gray-300 cursor-pointer">
|
>
|
||||||
${this.escapeHTML(profile.name)}
|
</div>
|
||||||
</p>
|
<div class="min-w-0">
|
||||||
<div class="flex items-center text-xs text-gray-500 mt-1">
|
<p class="text-sm text-gray-400 hover:text-gray-300 cursor-pointer">
|
||||||
<span>${timeAgo}</span>
|
${this.escapeHTML(profile.name)}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center text-xs text-gray-500 mt-1">
|
||||||
|
<span>${timeAgo}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Right: gearMenu if user owns the video -->
|
||||||
|
${gearMenu}
|
||||||
</div>
|
</div>
|
||||||
${editButton}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -490,7 +576,7 @@ class NosTubeApp {
|
|||||||
console.log('Rendered videos:', renderedVideos.length);
|
console.log('Rendered videos:', renderedVideos.length);
|
||||||
|
|
||||||
if (renderedVideos.length === 0) {
|
if (renderedVideos.length === 0) {
|
||||||
this.videoList.innerHTML = '<p class="text-center text-gray-500">No valid videos to display.</p>';
|
this.videoList.innerHTML = `<p class="text-center text-gray-500">No valid videos to display.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,13 +585,12 @@ class NosTubeApp {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Rendering error:', error);
|
console.error('Rendering error:', error);
|
||||||
this.videoList.innerHTML = '<p class="text-center text-gray-500">Error loading videos.</p>';
|
this.videoList.innerHTML = `<p class="text-center text-gray-500">Error loading videos.</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a video object
|
* Validates a video object
|
||||||
* Updated to include event ID validation
|
|
||||||
*/
|
*/
|
||||||
validateVideo(video, index) {
|
validateVideo(video, index) {
|
||||||
const validationResults = {
|
const validationResults = {
|
||||||
@@ -604,23 +689,18 @@ class NosTubeApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode the magnet URI
|
|
||||||
const decodedMagnet = decodeURIComponent(magnetURI);
|
const decodedMagnet = decodeURIComponent(magnetURI);
|
||||||
|
|
||||||
// Don't restart if it's the same video
|
|
||||||
if (this.currentMagnetUri === decodedMagnet) {
|
if (this.currentMagnetUri === decodedMagnet) {
|
||||||
this.log('Same video requested - already playing');
|
this.log('Same video requested - already playing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store current magnet URI
|
|
||||||
this.currentMagnetUri = decodedMagnet;
|
this.currentMagnetUri = decodedMagnet;
|
||||||
|
|
||||||
// Show the modal first
|
|
||||||
this.playerModal.style.display = 'flex';
|
this.playerModal.style.display = 'flex';
|
||||||
this.playerModal.classList.remove('hidden');
|
this.playerModal.classList.remove('hidden');
|
||||||
|
|
||||||
// Find the video data
|
// Re-fetch the latest from relays
|
||||||
const videos = await nostrClient.fetchVideos();
|
const videos = await nostrClient.fetchVideos();
|
||||||
const video = videos.find(v => v.magnet === decodedMagnet);
|
const video = videos.find(v => v.magnet === decodedMagnet);
|
||||||
|
|
||||||
@@ -629,7 +709,17 @@ class NosTubeApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch creator profile
|
// Decrypt only once if user owns it
|
||||||
|
if (video.isPrivate && video.pubkey === this.pubkey && !video.alreadyDecrypted) {
|
||||||
|
this.log('User owns a private video => decrypting magnet link...');
|
||||||
|
video.magnet = fakeDecrypt(video.magnet);
|
||||||
|
// Mark it so we don't do it again
|
||||||
|
video.alreadyDecrypted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalMagnet = video.magnet;
|
||||||
|
|
||||||
|
// Profile fetch
|
||||||
let creatorProfile = { name: 'Unknown', picture: `https://robohash.org/${video.pubkey}` };
|
let creatorProfile = { name: 'Unknown', picture: `https://robohash.org/${video.pubkey}` };
|
||||||
try {
|
try {
|
||||||
const userEvents = await nostrClient.pool.list(nostrClient.relays, [{
|
const userEvents = await nostrClient.pool.list(nostrClient.relays, [{
|
||||||
@@ -638,7 +728,8 @@ class NosTubeApp {
|
|||||||
limit: 1
|
limit: 1
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
if (userEvents[0]?.content) {
|
// Ensure userEvents isn't empty before accessing [0]
|
||||||
|
if (userEvents.length > 0 && userEvents[0]?.content) {
|
||||||
const profile = JSON.parse(userEvents[0].content);
|
const profile = JSON.parse(userEvents[0].content);
|
||||||
creatorProfile = {
|
creatorProfile = {
|
||||||
name: profile.name || profile.display_name || 'Unknown',
|
name: profile.name || profile.display_name || 'Unknown',
|
||||||
@@ -649,7 +740,6 @@ class NosTubeApp {
|
|||||||
this.log('Error fetching creator profile:', error);
|
this.log('Error fetching creator profile:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert pubkey to npub
|
|
||||||
let creatorNpub = 'Unknown';
|
let creatorNpub = 'Unknown';
|
||||||
try {
|
try {
|
||||||
creatorNpub = window.NostrTools.nip19.npubEncode(video.pubkey);
|
creatorNpub = window.NostrTools.nip19.npubEncode(video.pubkey);
|
||||||
@@ -658,24 +748,19 @@ class NosTubeApp {
|
|||||||
creatorNpub = video.pubkey;
|
creatorNpub = video.pubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update video info
|
|
||||||
this.videoTitle.textContent = video.title || 'Untitled';
|
this.videoTitle.textContent = video.title || 'Untitled';
|
||||||
this.videoDescription.textContent = video.description || 'No description available.';
|
this.videoDescription.textContent = video.description || 'No description available.';
|
||||||
this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at);
|
this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at);
|
||||||
|
|
||||||
// Update creator info
|
|
||||||
this.creatorName.textContent = creatorProfile.name;
|
this.creatorName.textContent = creatorProfile.name;
|
||||||
this.creatorNpub.textContent = `${creatorNpub.slice(0, 8)}...${creatorNpub.slice(-4)}`;
|
this.creatorNpub.textContent = `${creatorNpub.slice(0, 8)}...${creatorNpub.slice(-4)}`;
|
||||||
this.creatorAvatar.src = creatorProfile.picture;
|
this.creatorAvatar.src = creatorProfile.picture;
|
||||||
this.creatorAvatar.alt = creatorProfile.name;
|
this.creatorAvatar.alt = creatorProfile.name;
|
||||||
|
|
||||||
// Start streaming
|
this.log('Starting video stream with:', finalMagnet);
|
||||||
this.log('Starting video stream:', decodedMagnet);
|
await torrentClient.streamVideo(finalMagnet, this.modalVideo);
|
||||||
await torrentClient.streamVideo(decodedMagnet, this.modalVideo);
|
|
||||||
|
|
||||||
// Update UI elements based on existing DOM elements that webtorrent.js updates
|
|
||||||
const updateInterval = setInterval(() => {
|
const updateInterval = setInterval(() => {
|
||||||
// Check if modal is still visible
|
|
||||||
if (!document.body.contains(this.modalVideo)) {
|
if (!document.body.contains(this.modalVideo)) {
|
||||||
clearInterval(updateInterval);
|
clearInterval(updateInterval);
|
||||||
return;
|
return;
|
||||||
@@ -687,10 +772,10 @@ class NosTubeApp {
|
|||||||
const speed = document.getElementById('speed');
|
const speed = document.getElementById('speed');
|
||||||
const downloaded = document.getElementById('downloaded');
|
const downloaded = document.getElementById('downloaded');
|
||||||
|
|
||||||
if (status) this.modalStatus.textContent = status.textContent;
|
if (status) this.modalStatus.textContent = status.textContent;
|
||||||
if (progress) this.modalProgress.style.width = progress.style.width;
|
if (progress) this.modalProgress.style.width = progress.style.width;
|
||||||
if (peers) this.modalPeers.textContent = peers.textContent;
|
if (peers) this.modalPeers.textContent = peers.textContent;
|
||||||
if (speed) this.modalSpeed.textContent = speed.textContent;
|
if (speed) this.modalSpeed.textContent = speed.textContent;
|
||||||
if (downloaded) this.modalDownloaded.textContent = downloaded.textContent;
|
if (downloaded) this.modalDownloaded.textContent = downloaded.textContent;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
@@ -700,9 +785,6 @@ class NosTubeApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the UI with the current torrent status.
|
|
||||||
*/
|
|
||||||
updateTorrentStatus(torrent) {
|
updateTorrentStatus(torrent) {
|
||||||
if (!torrent) return;
|
if (!torrent) return;
|
||||||
|
|
||||||
@@ -712,7 +794,6 @@ class NosTubeApp {
|
|||||||
this.modalSpeed.textContent = `${(torrent.downloadSpeed / 1024).toFixed(2)} KB/s`;
|
this.modalSpeed.textContent = `${(torrent.downloadSpeed / 1024).toFixed(2)} KB/s`;
|
||||||
this.modalDownloaded.textContent = `${(torrent.downloaded / (1024 * 1024)).toFixed(2)} MB / ${(torrent.length / (1024 * 1024)).toFixed(2)} MB`;
|
this.modalDownloaded.textContent = `${(torrent.downloaded / (1024 * 1024)).toFixed(2)} MB / ${(torrent.length / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
|
||||||
// Update periodically
|
|
||||||
if (torrent.ready) {
|
if (torrent.ready) {
|
||||||
this.modalStatus.textContent = 'Ready to play';
|
this.modalStatus.textContent = 'Ready to play';
|
||||||
} else {
|
} else {
|
||||||
@@ -723,7 +804,6 @@ class NosTubeApp {
|
|||||||
/**
|
/**
|
||||||
* Allows the user to edit a video note (only if they are the owner).
|
* Allows the user to edit a video note (only if they are the owner).
|
||||||
* We reuse the note's existing d tag via nostrClient.editVideo.
|
* We reuse the note's existing d tag via nostrClient.editVideo.
|
||||||
* @param {number} index - The index of the video in the rendered list
|
|
||||||
*/
|
*/
|
||||||
async handleEditVideo(index) {
|
async handleEditVideo(index) {
|
||||||
try {
|
try {
|
||||||
@@ -739,33 +819,25 @@ class NosTubeApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prompt for new fields, but leave old value if user cancels or leaves blank.
|
// Prompt for new fields or keep old
|
||||||
const newTitle = prompt('New Title? (Leave blank to keep existing)', video.title);
|
const newTitle = prompt('New Title? (Leave blank to keep existing)', video.title);
|
||||||
const newMagnet = prompt('New Magnet Link? (Leave blank to keep existing)', video.magnet);
|
const newMagnet = prompt('New Magnet? (Leave blank to keep existing)', video.magnet);
|
||||||
const newThumbnail = prompt('New Thumbnail URL? (Leave blank to keep existing)', video.thumbnail);
|
const newThumbnail = prompt('New Thumbnail? (Leave blank to keep existing)', video.thumbnail);
|
||||||
const newDescription = prompt('New Description? (Leave blank to keep existing)', video.description);
|
const newDescription = prompt('New Description? (Leave blank to keep existing)', video.description);
|
||||||
|
|
||||||
// If user cancels ANY prompt, it returns `null`.
|
// Ask user if they want the note private or public
|
||||||
// If user typed nothing and clicked OK, it’s an empty string ''.
|
const wantPrivate = confirm('Make this video private? OK=Yes, Cancel=No');
|
||||||
// So we do checks to keep the old value if needed:
|
|
||||||
const title = (newTitle === null || newTitle.trim() === '')
|
|
||||||
? video.title
|
|
||||||
: newTitle.trim();
|
|
||||||
|
|
||||||
const magnet = (newMagnet === null || newMagnet.trim() === '')
|
// Fallback to old if user typed nothing
|
||||||
? video.magnet
|
const title = (newTitle === null || newTitle.trim() === '') ? video.title : newTitle.trim();
|
||||||
: newMagnet.trim();
|
const magnet = (newMagnet === null || newMagnet.trim() === '') ? video.magnet : newMagnet.trim();
|
||||||
|
const thumbnail = (newThumbnail === null || newThumbnail.trim() === '') ? video.thumbnail : newThumbnail.trim();
|
||||||
|
const description = (newDescription === null || newDescription.trim() === '') ? video.description : newDescription.trim();
|
||||||
|
|
||||||
const thumbnail = (newThumbnail === null || newThumbnail.trim() === '')
|
// Build final updated data
|
||||||
? video.thumbnail
|
|
||||||
: newThumbnail.trim();
|
|
||||||
|
|
||||||
const description = (newDescription === null || newDescription.trim() === '')
|
|
||||||
? video.description
|
|
||||||
: newDescription.trim();
|
|
||||||
|
|
||||||
// Build updated data
|
|
||||||
const updatedData = {
|
const updatedData = {
|
||||||
|
version: video.version || 2, // keep old version or set 2
|
||||||
|
isPrivate: wantPrivate,
|
||||||
title,
|
title,
|
||||||
magnet,
|
magnet,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
@@ -773,12 +845,12 @@ class NosTubeApp {
|
|||||||
mode: isDevMode ? 'dev' : 'live'
|
mode: isDevMode ? 'dev' : 'live'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Edit
|
||||||
const originalEvent = {
|
const originalEvent = {
|
||||||
id: video.id,
|
id: video.id,
|
||||||
pubkey: video.pubkey,
|
pubkey: video.pubkey,
|
||||||
tags: video.tags // Must include ["d","someValue"] to reuse the same note
|
tags: video.tags
|
||||||
};
|
};
|
||||||
|
|
||||||
await nostrClient.editVideo(originalEvent, updatedData, this.pubkey);
|
await nostrClient.editVideo(originalEvent, updatedData, this.pubkey);
|
||||||
this.showSuccess('Video updated successfully!');
|
this.showSuccess('Video updated successfully!');
|
||||||
await this.loadVideos();
|
await this.loadVideos();
|
||||||
@@ -788,12 +860,44 @@ class NosTubeApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADDED FOR VERSIONING/PRIVATE/DELETE:
|
||||||
|
* Allows the user to delete (soft-delete) a video by marking it as deleted.
|
||||||
|
*/
|
||||||
|
async handleDeleteVideo(index) {
|
||||||
|
try {
|
||||||
|
const videos = await nostrClient.fetchVideos();
|
||||||
|
const video = videos[index];
|
||||||
|
|
||||||
|
if (!this.pubkey) {
|
||||||
|
this.showError('Please login to delete videos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (video.pubkey !== this.pubkey) {
|
||||||
|
this.showError('You do not own this video.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to delete "${video.title}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalEvent = {
|
||||||
|
id: video.id,
|
||||||
|
pubkey: video.pubkey,
|
||||||
|
tags: video.tags
|
||||||
|
};
|
||||||
|
|
||||||
|
await nostrClient.deleteVideo(originalEvent, this.pubkey);
|
||||||
|
this.showSuccess('Video deleted (hidden) successfully!');
|
||||||
|
await this.loadVideos();
|
||||||
|
} catch (err) {
|
||||||
|
this.log('Failed to delete video:', err.message);
|
||||||
|
this.showError('Failed to delete video. Please try again later.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const app = new NosTubeApp();
|
export const app = new NosTubeApp();
|
||||||
|
|
||||||
// Initialize app
|
|
||||||
app.init();
|
app.init();
|
||||||
|
|
||||||
// Make playVideo accessible globally for the onclick handlers
|
|
||||||
window.app = app;
|
window.app = app;
|
||||||
|
237
src/js/nostr.js
237
src/js/nostr.js
@@ -26,9 +26,20 @@ function logErrorOnce(message, eventContent = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A very naive "encryption" function that just reverses the string.
|
||||||
|
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
|
||||||
|
*/
|
||||||
|
function fakeEncrypt(magnet) {
|
||||||
|
return magnet.split('').reverse().join('');
|
||||||
|
}
|
||||||
|
function fakeDecrypt(encrypted) {
|
||||||
|
return encrypted.split('').reverse().join('');
|
||||||
|
}
|
||||||
|
|
||||||
class NostrClient {
|
class NostrClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pool = null; // Initialize to null, we'll create it in init()
|
this.pool = null;
|
||||||
this.pubkey = null;
|
this.pubkey = null;
|
||||||
this.relays = RELAY_URLS;
|
this.relays = RELAY_URLS;
|
||||||
}
|
}
|
||||||
@@ -40,17 +51,14 @@ class NostrClient {
|
|||||||
try {
|
try {
|
||||||
if (isDevMode) console.log('Connecting to relays...');
|
if (isDevMode) console.log('Connecting to relays...');
|
||||||
|
|
||||||
// Initialize the pool
|
|
||||||
this.pool = new window.NostrTools.SimplePool();
|
this.pool = new window.NostrTools.SimplePool();
|
||||||
|
|
||||||
// Test relay connections
|
const testFilter = { kinds: [0], limit: 1 };
|
||||||
const testFilter = { kinds: [0], limit: 1 }; // Dummy filter for testing
|
|
||||||
const connections = this.relays.map(async url => {
|
const connections = this.relays.map(async url => {
|
||||||
try {
|
try {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const sub = this.pool.sub([url], [testFilter]);
|
const sub = this.pool.sub([url], [testFilter]);
|
||||||
|
|
||||||
// Set a timeout for connection attempts
|
|
||||||
let timeout = setTimeout(() => {
|
let timeout = setTimeout(() => {
|
||||||
sub.unsub();
|
sub.unsub();
|
||||||
if (isDevMode) console.log(`Connection timeout for ${url}`);
|
if (isDevMode) console.log(`Connection timeout for ${url}`);
|
||||||
@@ -84,7 +92,9 @@ class NostrClient {
|
|||||||
throw new Error('No relays could be connected.');
|
throw new Error('No relays could be connected.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDevMode) console.log(`Connected to ${successfulRelays.length} relay(s):`, successfulRelays);
|
if (isDevMode) {
|
||||||
|
console.log(`Connected to ${successfulRelays.length} relay(s):`, successfulRelays);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to initialize Nostr client:', err.message);
|
console.error('Failed to initialize Nostr client:', err.message);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -143,52 +153,62 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publishes a new video event to all relays (creates a new note).
|
* Publishes a new video event to all relays (creates a brand-new note).
|
||||||
*/
|
*/
|
||||||
async publishVideo(videoData, pubkey) {
|
async publishVideo(videoData, pubkey) {
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
throw new Error('User is not logged in.');
|
throw new Error('User is not logged in.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugging Log: Check videoData
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log('Publishing video with data:', videoData);
|
console.log('Publishing video with data:', videoData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique "d" tag for this event to prevent overwriting
|
// If user sets "isPrivate = true", encrypt the magnet
|
||||||
|
let finalMagnet = videoData.magnet;
|
||||||
|
if (videoData.isPrivate === true) {
|
||||||
|
finalMagnet = fakeEncrypt(finalMagnet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default version is 1 if not specified
|
||||||
|
const version = videoData.version ?? 1;
|
||||||
|
|
||||||
const uniqueD = `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
|
const uniqueD = `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
|
||||||
|
|
||||||
// Construct the event object
|
// Always mark "deleted" false for new posts
|
||||||
|
const contentObject = {
|
||||||
|
version,
|
||||||
|
deleted: false,
|
||||||
|
isPrivate: videoData.isPrivate || false,
|
||||||
|
title: videoData.title,
|
||||||
|
magnet: finalMagnet,
|
||||||
|
thumbnail: videoData.thumbnail,
|
||||||
|
description: videoData.description,
|
||||||
|
mode: videoData.mode
|
||||||
|
};
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
pubkey,
|
pubkey,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
// Keep your original 't=video' tag
|
|
||||||
// Add a new 'd' tag using a unique value
|
|
||||||
tags: [
|
tags: [
|
||||||
['t', 'video'],
|
['t', 'video'],
|
||||||
['d', uniqueD]
|
['d', uniqueD]
|
||||||
],
|
],
|
||||||
// Include the JSON content (title, magnet, description, etc.)
|
content: JSON.stringify(contentObject)
|
||||||
content: JSON.stringify(videoData)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debugging Log: Check stringified content
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log('Event content after stringify:', event.content);
|
console.log('Event content after stringify:', event.content);
|
||||||
console.log('Using d tag:', uniqueD);
|
console.log('Using d tag:', uniqueD);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Sign the event with Nostr extension (or other method)
|
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
|
||||||
// Debugging Log: Check signed event
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log('Signed event:', signedEvent);
|
console.log('Signed event:', signedEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish signed event to all configured relays
|
|
||||||
await Promise.all(this.relays.map(async url => {
|
await Promise.all(this.relays.map(async url => {
|
||||||
try {
|
try {
|
||||||
await this.pool.publish([url], signedEvent);
|
await this.pool.publish([url], signedEvent);
|
||||||
@@ -202,9 +222,7 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Return the signed event for any further handling
|
|
||||||
return signedEvent;
|
return signedEvent;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.error('Failed to sign event:', error.message);
|
console.error('Failed to sign event:', error.message);
|
||||||
@@ -214,11 +232,10 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edits an existing video event by reusing its "d" tag.
|
* Edits an existing video event by reusing the same "d" tag.
|
||||||
* @param {Object} originalEvent - The entire event object you're editing.
|
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
|
||||||
* @param {Object} updatedVideoData - The updated fields (title, magnet, etc.).
|
|
||||||
* @param {string} pubkey - The user's pubkey (must match originalEvent.pubkey).
|
|
||||||
*/
|
*/
|
||||||
|
// Minimal fix: ensures we only ever encrypt once per edit operation
|
||||||
async editVideo(originalEvent, updatedVideoData, pubkey) {
|
async editVideo(originalEvent, updatedVideoData, pubkey) {
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
throw new Error('User is not logged in.');
|
throw new Error('User is not logged in.');
|
||||||
@@ -227,7 +244,6 @@ class NostrClient {
|
|||||||
throw new Error('You do not own this event (different pubkey).');
|
throw new Error('You do not own this event (different pubkey).');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugging log
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log('Editing video event:', originalEvent);
|
console.log('Editing video event:', originalEvent);
|
||||||
console.log('New video data:', updatedVideoData);
|
console.log('New video data:', updatedVideoData);
|
||||||
@@ -240,7 +256,57 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
const existingD = dTag[1];
|
const existingD = dTag[1];
|
||||||
|
|
||||||
// Build the updated event with the same (kind, pubkey, d) so relays see it as an update
|
// Parse old content
|
||||||
|
const oldContent = JSON.parse(originalEvent.content || '{}');
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log('Old content:', oldContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep old version & deleted status
|
||||||
|
const oldVersion = oldContent.version ?? 1;
|
||||||
|
const oldDeleted = (oldContent.deleted === true);
|
||||||
|
const newVersion = updatedVideoData.version ?? oldVersion;
|
||||||
|
|
||||||
|
const oldWasPrivate = (oldContent.isPrivate === true);
|
||||||
|
|
||||||
|
// 1) If old was private, decrypt the old magnet once => oldPlainMagnet
|
||||||
|
let oldPlainMagnet = oldContent.magnet || '';
|
||||||
|
if (oldWasPrivate && oldPlainMagnet) {
|
||||||
|
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) If updatedVideoData.isPrivate is explicitly set, use that; else keep the old isPrivate
|
||||||
|
const newIsPrivate =
|
||||||
|
typeof updatedVideoData.isPrivate === 'boolean'
|
||||||
|
? updatedVideoData.isPrivate
|
||||||
|
: (oldContent.isPrivate ?? false);
|
||||||
|
|
||||||
|
// 3) The user might type a new magnet or keep oldPlainMagnet
|
||||||
|
const userTypedMagnet = (updatedVideoData.magnet || '').trim();
|
||||||
|
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
|
||||||
|
|
||||||
|
// 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext
|
||||||
|
let finalMagnet = finalPlainMagnet;
|
||||||
|
if (newIsPrivate) {
|
||||||
|
finalMagnet = fakeEncrypt(finalPlainMagnet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build updated content
|
||||||
|
const contentObject = {
|
||||||
|
version: newVersion,
|
||||||
|
deleted: oldDeleted,
|
||||||
|
isPrivate: newIsPrivate,
|
||||||
|
title: updatedVideoData.title,
|
||||||
|
magnet: finalMagnet,
|
||||||
|
thumbnail: updatedVideoData.thumbnail,
|
||||||
|
description: updatedVideoData.description,
|
||||||
|
mode: updatedVideoData.mode
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log('Building updated content object:', contentObject);
|
||||||
|
}
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
pubkey,
|
pubkey,
|
||||||
@@ -249,7 +315,7 @@ class NostrClient {
|
|||||||
['t', 'video'],
|
['t', 'video'],
|
||||||
['d', existingD]
|
['d', existingD]
|
||||||
],
|
],
|
||||||
content: JSON.stringify(updatedVideoData)
|
content: JSON.stringify(contentObject)
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
@@ -258,14 +324,12 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Sign the new updated event
|
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log('Signed edited event:', signedEvent);
|
console.log('Signed edited event:', signedEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish the edited event to all relays
|
// Publish to all relays
|
||||||
await Promise.all(this.relays.map(async url => {
|
await Promise.all(this.relays.map(async url => {
|
||||||
try {
|
try {
|
||||||
await this.pool.publish([url], signedEvent);
|
await this.pool.publish([url], signedEvent);
|
||||||
@@ -288,18 +352,97 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete or hide an existing video by marking content as "deleted: true"
|
||||||
|
* and republishing with same (kind=30078, pubkey, d) address.
|
||||||
|
*/
|
||||||
|
async deleteVideo(originalEvent, pubkey) {
|
||||||
|
if (!pubkey) {
|
||||||
|
throw new Error('User is not logged in.');
|
||||||
|
}
|
||||||
|
if (originalEvent.pubkey !== pubkey) {
|
||||||
|
throw new Error('You do not own this event (different pubkey).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log('Deleting video event:', originalEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dTag = originalEvent.tags.find(tag => tag[0] === 'd');
|
||||||
|
if (!dTag) {
|
||||||
|
throw new Error('This event has no "d" tag, cannot delete as addressable kind=30078.');
|
||||||
|
}
|
||||||
|
const existingD = dTag[1];
|
||||||
|
|
||||||
|
const oldContent = JSON.parse(originalEvent.content || '{}');
|
||||||
|
const oldVersion = oldContent.version ?? 1;
|
||||||
|
|
||||||
|
const contentObject = {
|
||||||
|
version: oldVersion,
|
||||||
|
deleted: true,
|
||||||
|
title: oldContent.title || '',
|
||||||
|
magnet: '',
|
||||||
|
thumbnail: '',
|
||||||
|
description: 'This video has been deleted.',
|
||||||
|
mode: oldContent.mode || 'live',
|
||||||
|
isPrivate: oldContent.isPrivate || false
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
kind: 30078,
|
||||||
|
pubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['t', 'video'],
|
||||||
|
['d', existingD]
|
||||||
|
],
|
||||||
|
content: JSON.stringify(contentObject)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log('Reusing d tag for delete:', existingD);
|
||||||
|
console.log('Deleted event content:', event.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log('Signed deleted event:', signedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(this.relays.map(async url => {
|
||||||
|
try {
|
||||||
|
await this.pool.publish([url], signedEvent);
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log(`Deleted event published to ${url} (d="${existingD}")`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.error(`Failed to publish deleted event to ${url}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return signedEvent;
|
||||||
|
} catch (error) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.error('Failed to sign deleted event:', error.message);
|
||||||
|
}
|
||||||
|
throw new Error('Failed to sign deleted event.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches videos from all configured relays.
|
* Fetches videos from all configured relays.
|
||||||
*/
|
*/
|
||||||
async fetchVideos() {
|
async fetchVideos() {
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [30078], // The kind you use for video notes
|
kinds: [30078],
|
||||||
'#t': ['video'], // Tag "t" must include "video"
|
'#t': ['video'],
|
||||||
limit: 1000, // Large limit to capture many events
|
limit: 1000,
|
||||||
since: 0 // Fetch from the earliest possible event
|
since: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use a Map so duplicates (same event ID) across multiple relays don't overwrite each other
|
|
||||||
const videoEvents = new Map();
|
const videoEvents = new Map();
|
||||||
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
@@ -308,7 +451,6 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch from each relay in parallel
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.relays.map(async (url) => {
|
this.relays.map(async (url) => {
|
||||||
if (isDevMode) console.log(`[fetchVideos] Querying relay: ${url}`);
|
if (isDevMode) console.log(`[fetchVideos] Querying relay: ${url}`);
|
||||||
@@ -327,15 +469,22 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each event
|
|
||||||
events.forEach(event => {
|
events.forEach(event => {
|
||||||
try {
|
try {
|
||||||
const content = JSON.parse(event.content);
|
const content = JSON.parse(event.content);
|
||||||
|
|
||||||
// Only add if we haven't seen this event.id before
|
// If deleted == true, it overrides older notes
|
||||||
|
if (content.deleted === true) {
|
||||||
|
videoEvents.delete(event.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we haven't seen this event.id before, store it
|
||||||
if (!videoEvents.has(event.id)) {
|
if (!videoEvents.has(event.id)) {
|
||||||
videoEvents.set(event.id, {
|
videoEvents.set(event.id, {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
version: content.version ?? 1,
|
||||||
|
isPrivate: content.isPrivate ?? false,
|
||||||
title: content.title || '',
|
title: content.title || '',
|
||||||
magnet: content.magnet || '',
|
magnet: content.magnet || '',
|
||||||
thumbnail: content.thumbnail || '',
|
thumbnail: content.thumbnail || '',
|
||||||
@@ -343,7 +492,6 @@ class NostrClient {
|
|||||||
mode: content.mode || 'live',
|
mode: content.mode || 'live',
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
created_at: event.created_at,
|
created_at: event.created_at,
|
||||||
// Keep the original tags array in case we need them later (e.g. for editing)
|
|
||||||
tags: event.tags
|
tags: event.tags
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -361,21 +509,12 @@ class NostrClient {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert Map to array and sort by creation time (descending)
|
|
||||||
const videos = Array.from(videoEvents.values())
|
const videos = Array.from(videoEvents.values())
|
||||||
.sort((a, b) => b.created_at - a.created_at);
|
.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log('[fetchVideos] All relays have responded.');
|
console.log('[fetchVideos] All relays have responded.');
|
||||||
console.log(`[fetchVideos] Total unique video events: ${videoEvents.size}`);
|
console.log(`[fetchVideos] Total unique video events: ${videoEvents.size}`);
|
||||||
console.log(
|
|
||||||
'[fetchVideos] Final videos array (sorted):',
|
|
||||||
videos.map(v => ({
|
|
||||||
title: v.title,
|
|
||||||
pubkey: v.pubkey,
|
|
||||||
created_at: new Date(v.created_at * 1000).toISOString()
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return videos;
|
return videos;
|
||||||
|
Reference in New Issue
Block a user