mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-09 15:38:44 +00:00
added channel/profile view
This commit is contained in:
@@ -26,27 +26,6 @@
|
|||||||
<div
|
<div
|
||||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
|
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<!-- If you want an X to close, you can add it here, for example:
|
|
||||||
<button
|
|
||||||
id="closeDisclaimerBtn"
|
|
||||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 transition-all duration-200 backdrop-blur focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="w-6 h-6 text-gray-300"
|
|
||||||
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>
|
|
||||||
-->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
|
@@ -464,7 +464,6 @@ footer a:hover {
|
|||||||
padding-top: 56.25%;
|
padding-top: 56.25%;
|
||||||
background-color: #1e293b;
|
background-color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ratio-16-9 > img {
|
.ratio-16-9 > img {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -526,3 +525,4 @@ footer a:hover {
|
|||||||
#sidebar hr {
|
#sidebar hr {
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
154
js/app.js
154
js/app.js
@@ -299,10 +299,8 @@ class bitvidApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* After we load the video modal, store references in `this.*`.
|
|
||||||
*/
|
|
||||||
updateModalElements() {
|
updateModalElements() {
|
||||||
|
// Existing references
|
||||||
this.playerModal = document.getElementById("playerModal") || null;
|
this.playerModal = document.getElementById("playerModal") || null;
|
||||||
this.modalVideo = document.getElementById("modalVideo") || null;
|
this.modalVideo = document.getElementById("modalVideo") || null;
|
||||||
this.modalStatus = document.getElementById("modalStatus") || null;
|
this.modalStatus = document.getElementById("modalStatus") || null;
|
||||||
@@ -315,20 +313,22 @@ class bitvidApp {
|
|||||||
this.videoTitle = document.getElementById("videoTitle") || null;
|
this.videoTitle = document.getElementById("videoTitle") || null;
|
||||||
this.videoDescription = document.getElementById("videoDescription") || null;
|
this.videoDescription = document.getElementById("videoDescription") || null;
|
||||||
this.videoTimestamp = document.getElementById("videoTimestamp") || null;
|
this.videoTimestamp = document.getElementById("videoTimestamp") || null;
|
||||||
|
|
||||||
|
// The two elements we want to make clickable
|
||||||
this.creatorAvatar = document.getElementById("creatorAvatar") || null;
|
this.creatorAvatar = document.getElementById("creatorAvatar") || null;
|
||||||
this.creatorName = document.getElementById("creatorName") || null;
|
this.creatorName = document.getElementById("creatorName") || null;
|
||||||
this.creatorNpub = document.getElementById("creatorNpub") || null;
|
this.creatorNpub = document.getElementById("creatorNpub") || null;
|
||||||
|
|
||||||
|
// Copy/Share buttons
|
||||||
this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null;
|
this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null;
|
||||||
this.shareBtn = document.getElementById("shareBtn") || null;
|
this.shareBtn = document.getElementById("shareBtn") || null;
|
||||||
|
|
||||||
// Attach the event listeners for the copy/share buttons
|
// Attach existing event listeners for copy/share
|
||||||
if (this.copyMagnetBtn) {
|
if (this.copyMagnetBtn) {
|
||||||
this.copyMagnetBtn.addEventListener("click", () => {
|
this.copyMagnetBtn.addEventListener("click", () => {
|
||||||
this.handleCopyMagnet();
|
this.handleCopyMagnet();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPDATED: This share button just copies the ?v= URL to the clipboard:
|
|
||||||
if (this.shareBtn) {
|
if (this.shareBtn) {
|
||||||
this.shareBtn.addEventListener("click", () => {
|
this.shareBtn.addEventListener("click", () => {
|
||||||
if (!this.currentVideo) {
|
if (!this.currentVideo) {
|
||||||
@@ -350,6 +350,56 @@ class bitvidApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add click handlers for avatar and name => channel profile
|
||||||
|
if (this.creatorAvatar) {
|
||||||
|
this.creatorAvatar.style.cursor = "pointer";
|
||||||
|
this.creatorAvatar.addEventListener("click", () => {
|
||||||
|
this.openCreatorChannel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.creatorName) {
|
||||||
|
this.creatorName.style.cursor = "pointer";
|
||||||
|
this.creatorName.addEventListener("click", () => {
|
||||||
|
this.openCreatorChannel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goToProfile(pubkey) {
|
||||||
|
if (!pubkey) {
|
||||||
|
this.showError("No creator info available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||||
|
// Switch to channel profile view
|
||||||
|
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to go to channel:", err);
|
||||||
|
this.showError("Could not open channel.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openCreatorChannel() {
|
||||||
|
if (!this.currentVideo || !this.currentVideo.pubkey) {
|
||||||
|
this.showError("No creator info available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Encode the hex pubkey to npub
|
||||||
|
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
|
||||||
|
|
||||||
|
// Close the video modal
|
||||||
|
this.hideModal();
|
||||||
|
|
||||||
|
// Switch to channel profile view
|
||||||
|
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to open creator channel:", err);
|
||||||
|
this.showError("Could not open channel.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1045,7 +1095,7 @@ class bitvidApp {
|
|||||||
async renderVideoList(videos) {
|
async renderVideoList(videos) {
|
||||||
if (!this.videoList) return;
|
if (!this.videoList) return;
|
||||||
|
|
||||||
// Check if there's anything to show
|
// 1) If no videos
|
||||||
if (!videos || videos.length === 0) {
|
if (!videos || videos.length === 0) {
|
||||||
this.videoList.innerHTML = `
|
this.videoList.innerHTML = `
|
||||||
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
||||||
@@ -1054,29 +1104,24 @@ class bitvidApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort newest first
|
// 2) Sort newest first
|
||||||
videos.sort((a, b) => b.created_at - a.created_at);
|
videos.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
// Convert allEvents to an array for checking older overshadowed events
|
|
||||||
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
|
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
// 1) Collect authors here so we can fetch profiles in one go
|
|
||||||
const authorSet = new Set();
|
const authorSet = new Set();
|
||||||
|
|
||||||
|
// 3) Build each card
|
||||||
videos.forEach((video, index) => {
|
videos.forEach((video, index) => {
|
||||||
if (!video.id || !video.title) {
|
if (!video.id || !video.title) {
|
||||||
console.error("Video missing ID/title:", video);
|
console.error("Video missing ID/title:", video);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track this author's pubkey for the batch fetch later
|
|
||||||
authorSet.add(video.pubkey);
|
authorSet.add(video.pubkey);
|
||||||
|
|
||||||
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
|
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
|
||||||
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
|
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(nevent)}`;
|
||||||
nevent
|
|
||||||
)}`;
|
|
||||||
const canEdit = video.pubkey === this.pubkey;
|
const canEdit = video.pubkey === this.pubkey;
|
||||||
const highlightClass =
|
const highlightClass =
|
||||||
video.isPrivate && canEdit
|
video.isPrivate && canEdit
|
||||||
@@ -1084,7 +1129,7 @@ class bitvidApp {
|
|||||||
: "border-none";
|
: "border-none";
|
||||||
const timeAgo = this.formatTimeAgo(video.created_at);
|
const timeAgo = this.formatTimeAgo(video.created_at);
|
||||||
|
|
||||||
// Check if there's an older version (for revert button)
|
// Check if there's an older version
|
||||||
let hasOlder = false;
|
let hasOlder = false;
|
||||||
if (canEdit && video.videoRootId) {
|
if (canEdit && video.videoRootId) {
|
||||||
hasOlder = this.hasOlderVersion(video, fullAllEventsArray);
|
hasOlder = this.hasOlderVersion(video, fullAllEventsArray);
|
||||||
@@ -1101,7 +1146,6 @@ class bitvidApp {
|
|||||||
`
|
`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Gear menu (only shown if canEdit)
|
|
||||||
const gearMenu = canEdit
|
const gearMenu = canEdit
|
||||||
? `
|
? `
|
||||||
<div class="relative inline-block ml-3 overflow-visible">
|
<div class="relative inline-block ml-3 overflow-visible">
|
||||||
@@ -1140,9 +1184,9 @@ class bitvidApp {
|
|||||||
`
|
`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Card markup
|
|
||||||
const cardHtml = `
|
const cardHtml = `
|
||||||
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
||||||
|
<!-- The clickable link to play video -->
|
||||||
<a
|
<a
|
||||||
href="${shareUrl}"
|
href="${shareUrl}"
|
||||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||||
@@ -1157,6 +1201,7 @@ class bitvidApp {
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
|
<!-- Title triggers the video modal as well -->
|
||||||
<h3
|
<h3
|
||||||
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
||||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||||
@@ -1182,6 +1227,7 @@ class bitvidApp {
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex items-center text-xs text-gray-500 mt-1">
|
<div class="flex items-center text-xs text-gray-500 mt-1">
|
||||||
<span>${timeAgo}</span>
|
<span>${timeAgo}</span>
|
||||||
|
<!-- We removed the 'Channel' button here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1191,15 +1237,13 @@ class bitvidApp {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Turn the HTML into an element
|
|
||||||
const template = document.createElement("template");
|
const template = document.createElement("template");
|
||||||
template.innerHTML = cardHtml.trim();
|
template.innerHTML = cardHtml.trim();
|
||||||
const cardEl = template.content.firstElementChild;
|
const cardEl = template.content.firstElementChild;
|
||||||
|
|
||||||
fragment.appendChild(cardEl);
|
fragment.appendChild(cardEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the list and add our fragment
|
// Clear old content, add new
|
||||||
this.videoList.innerHTML = "";
|
this.videoList.innerHTML = "";
|
||||||
this.videoList.appendChild(fragment);
|
this.videoList.appendChild(fragment);
|
||||||
|
|
||||||
@@ -1207,14 +1251,8 @@ class bitvidApp {
|
|||||||
const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
|
const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
|
||||||
lazyEls.forEach((el) => this.mediaLoader.observe(el));
|
lazyEls.forEach((el) => this.mediaLoader.observe(el));
|
||||||
|
|
||||||
// -------------------------------
|
// GEAR MENU / button event listeners...
|
||||||
// Gear menu / button event listeners
|
const gearButtons = this.videoList.querySelectorAll("[data-settings-dropdown]");
|
||||||
// -------------------------------
|
|
||||||
|
|
||||||
// Toggle the gear menu
|
|
||||||
const gearButtons = this.videoList.querySelectorAll(
|
|
||||||
"[data-settings-dropdown]"
|
|
||||||
);
|
|
||||||
gearButtons.forEach((button) => {
|
gearButtons.forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const index = button.getAttribute("data-settings-dropdown");
|
const index = button.getAttribute("data-settings-dropdown");
|
||||||
@@ -1225,43 +1263,33 @@ class bitvidApp {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit button
|
// Edit, Revert, Delete events omitted for brevity...
|
||||||
const editButtons = this.videoList.querySelectorAll("[data-edit-index]");
|
|
||||||
editButtons.forEach((button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const index = button.getAttribute("data-edit-index");
|
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${index}`);
|
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
|
||||||
this.handleEditVideo(index);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Revert button
|
|
||||||
const revertButtons = this.videoList.querySelectorAll("[data-revert-index]");
|
|
||||||
revertButtons.forEach((button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const index = button.getAttribute("data-revert-index");
|
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${index}`);
|
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
|
||||||
this.handleRevertVideo(index);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete All button
|
|
||||||
const deleteAllButtons = this.videoList.querySelectorAll(
|
|
||||||
"[data-delete-all-index]"
|
|
||||||
);
|
|
||||||
deleteAllButtons.forEach((button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const index = button.getAttribute("data-delete-all-index");
|
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${index}`);
|
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
|
||||||
this.handleFullDeleteVideo(index);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) After building cards, do one batch profile fetch
|
// 2) After building cards, do one batch profile fetch
|
||||||
this.batchFetchProfiles(authorSet);
|
this.batchFetchProfiles(authorSet);
|
||||||
|
|
||||||
|
// === NEW: attach click listeners to .author-pic and .author-name
|
||||||
|
const authorPics = this.videoList.querySelectorAll(".author-pic");
|
||||||
|
authorPics.forEach((pic) => {
|
||||||
|
pic.style.cursor = "pointer";
|
||||||
|
pic.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation(); // avoids playing the video
|
||||||
|
const pubkey = pic.getAttribute("data-pubkey");
|
||||||
|
this.goToProfile(pubkey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const authorNames = this.videoList.querySelectorAll(".author-name");
|
||||||
|
authorNames.forEach((nameEl) => {
|
||||||
|
nameEl.style.cursor = "pointer";
|
||||||
|
nameEl.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation(); // avoids playing the video
|
||||||
|
const pubkey = nameEl.getAttribute("data-pubkey");
|
||||||
|
this.goToProfile(pubkey);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
403
js/channelProfile.js
Normal file
403
js/channelProfile.js
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
// js/channelProfile.js
|
||||||
|
|
||||||
|
import { nostrClient } from "./nostr.js";
|
||||||
|
import { app } from "./app.js";
|
||||||
|
import { initialBlacklist, initialWhitelist } from "./lists.js";
|
||||||
|
import { isWhitelistEnabled } from "./config.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the channel profile view.
|
||||||
|
* Called when #view=channel-profile is active.
|
||||||
|
*/
|
||||||
|
export async function initChannelProfileView() {
|
||||||
|
// 1) Get npub from hash (e.g. #view=channel-profile&npub=...)
|
||||||
|
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
||||||
|
const npub = hashParams.get("npub");
|
||||||
|
if (!npub) {
|
||||||
|
console.error("No npub found in hash. Example: #view=channel-profile&npub=npub1...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Decode npub => hex pubkey
|
||||||
|
let hexPub;
|
||||||
|
try {
|
||||||
|
const decoded = window.NostrTools.nip19.decode(npub);
|
||||||
|
if (decoded.type === "npub" && decoded.data) {
|
||||||
|
hexPub = decoded.data;
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid npub decoding result.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error decoding npub:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Load user’s profile (banner, avatar, etc.)
|
||||||
|
await loadUserProfile(hexPub);
|
||||||
|
|
||||||
|
// 4) Load user’s videos (filtered and rendered like home feed)
|
||||||
|
await loadUserVideos(hexPub);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and displays the user’s metadata (kind:0).
|
||||||
|
*/
|
||||||
|
async function loadUserProfile(pubkey) {
|
||||||
|
try {
|
||||||
|
const events = await nostrClient.pool.list(nostrClient.relays, [
|
||||||
|
{ kinds: [0], authors: [pubkey], limit: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (events.length && events[0].content) {
|
||||||
|
const meta = JSON.parse(events[0].content);
|
||||||
|
|
||||||
|
// Banner
|
||||||
|
const bannerEl = document.getElementById("channelBanner");
|
||||||
|
if (bannerEl) {
|
||||||
|
bannerEl.src = meta.banner || "assets/jpg/default-banner.jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
const avatarEl = document.getElementById("channelAvatar");
|
||||||
|
if (avatarEl) {
|
||||||
|
avatarEl.src = meta.picture || "assets/svg/default-profile.svg";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel Name
|
||||||
|
const nameEl = document.getElementById("channelName");
|
||||||
|
if (nameEl) {
|
||||||
|
nameEl.textContent = meta.display_name || meta.name || "Unknown User";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel npub
|
||||||
|
const channelNpubEl = document.getElementById("channelNpub");
|
||||||
|
if (channelNpubEl) {
|
||||||
|
const userNpub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||||
|
channelNpubEl.textContent = userNpub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// About/Description
|
||||||
|
const aboutEl = document.getElementById("channelAbout");
|
||||||
|
if (aboutEl) {
|
||||||
|
aboutEl.textContent = meta.about || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Website
|
||||||
|
const websiteEl = document.getElementById("channelWebsite");
|
||||||
|
if (websiteEl) {
|
||||||
|
if (meta.website) {
|
||||||
|
websiteEl.href = meta.website;
|
||||||
|
websiteEl.textContent = meta.website;
|
||||||
|
} else {
|
||||||
|
websiteEl.textContent = "";
|
||||||
|
websiteEl.removeAttribute("href");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightning Address
|
||||||
|
const lnEl = document.getElementById("channelLightning");
|
||||||
|
if (lnEl) {
|
||||||
|
lnEl.textContent = meta.lud16 || meta.lud06 || "No lightning address found.";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("No metadata found for this user.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch user profile data:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and displays this user's videos (kind=30078),
|
||||||
|
* filtering out older/overshadowed events and blacklisted/non‐whitelisted entries.
|
||||||
|
* Renders the video cards using the same `.ratio-16-9` approach as the home view.
|
||||||
|
*/
|
||||||
|
async function loadUserVideos(pubkey) {
|
||||||
|
try {
|
||||||
|
// 1) Build filter for videos from this pubkey
|
||||||
|
const filter = {
|
||||||
|
kinds: [30078],
|
||||||
|
authors: [pubkey],
|
||||||
|
"#t": ["video"],
|
||||||
|
limit: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2) Collect raw events from all relays
|
||||||
|
const events = [];
|
||||||
|
for (const url of nostrClient.relays) {
|
||||||
|
try {
|
||||||
|
const result = await nostrClient.pool.list([url], [filter]);
|
||||||
|
events.push(...result);
|
||||||
|
} catch (relayErr) {
|
||||||
|
console.error(`Relay error (${url}):`, relayErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Convert events to "video" objects
|
||||||
|
let videos = [];
|
||||||
|
for (const evt of events) {
|
||||||
|
const vid = localConvertEventToVideo(evt);
|
||||||
|
if (!vid.invalid && !vid.deleted) {
|
||||||
|
videos.push(vid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Deduplicate older overshadowed versions (newest only)
|
||||||
|
videos = dedupeToNewestByRoot(videos);
|
||||||
|
|
||||||
|
// 5) Filter out blacklisted event IDs and authors
|
||||||
|
videos = videos.filter((video) => {
|
||||||
|
// Event-level blacklisting
|
||||||
|
if (app.blacklistedEventIds.has(video.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Author-level checks
|
||||||
|
const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||||||
|
if (initialBlacklist.includes(authorNpub)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6) Sort videos by newest first
|
||||||
|
videos.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
|
// 7) Render the videos in #channelVideoList
|
||||||
|
const container = document.getElementById("channelVideoList");
|
||||||
|
if (!container) {
|
||||||
|
console.warn("channelVideoList element not found in DOM.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = "";
|
||||||
|
if (!videos.length) {
|
||||||
|
container.innerHTML = `<p class="text-gray-500">No videos to display.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
// We'll store them in a local array so gear handlers match the indexes
|
||||||
|
const channelVideos = videos;
|
||||||
|
|
||||||
|
channelVideos.forEach((video, index) => {
|
||||||
|
// If private + the user is the owner => decrypt
|
||||||
|
if (
|
||||||
|
video.isPrivate &&
|
||||||
|
video.pubkey === nostrClient.pubkey &&
|
||||||
|
!video.alreadyDecrypted
|
||||||
|
) {
|
||||||
|
video.magnet = fakeDecrypt(video.magnet);
|
||||||
|
video.alreadyDecrypted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if the logged-in user can edit
|
||||||
|
const canEdit = video.pubkey === app.pubkey;
|
||||||
|
let gearMenu = "";
|
||||||
|
if (canEdit) {
|
||||||
|
gearMenu = `
|
||||||
|
<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"
|
||||||
|
data-settings-dropdown="${index}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="assets/svg/video-settings-gear.svg"
|
||||||
|
alt="Settings"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<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"
|
||||||
|
data-edit-index="${index}"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
data-delete-all-index="${index}"
|
||||||
|
>
|
||||||
|
Delete All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the `.ratio-16-9` approach from home feed
|
||||||
|
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
|
||||||
|
const safeThumb = video.thumbnail || fallbackThumb;
|
||||||
|
|
||||||
|
// Build the card
|
||||||
|
const cardEl = document.createElement("div");
|
||||||
|
cardEl.classList.add(
|
||||||
|
"bg-gray-900",
|
||||||
|
"rounded-lg",
|
||||||
|
"overflow-hidden",
|
||||||
|
"shadow-lg",
|
||||||
|
"hover:shadow-2xl",
|
||||||
|
"transition-all",
|
||||||
|
"duration-300"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The "ratio-16-9" container ensures 16:9 cropping
|
||||||
|
cardEl.innerHTML = `
|
||||||
|
<div class="cursor-pointer relative group">
|
||||||
|
<div class="ratio-16-9">
|
||||||
|
<img
|
||||||
|
src="${fallbackThumb}"
|
||||||
|
data-lazy="${escapeHTML(safeThumb)}"
|
||||||
|
alt="${escapeHTML(video.title)}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
class="text-lg font-bold text-white mb-2 line-clamp-2"
|
||||||
|
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||||
|
>
|
||||||
|
${escapeHTML(video.title)}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
${new Date(video.created_at * 1000).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
${gearMenu}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Clicking the card (except gear) => open video
|
||||||
|
cardEl.addEventListener("click", () => {
|
||||||
|
app.playVideoByEventId(video.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
fragment.appendChild(cardEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(fragment);
|
||||||
|
|
||||||
|
// Use app's lazy loader for thumbs
|
||||||
|
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||||
|
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
||||||
|
|
||||||
|
// Gear menus
|
||||||
|
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
|
||||||
|
gearButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const idx = btn.getAttribute("data-settings-dropdown");
|
||||||
|
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.toggle("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Edit" button
|
||||||
|
const editBtns = container.querySelectorAll("[data-edit-index]");
|
||||||
|
editBtns.forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
|
||||||
|
app.handleEditVideo(idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Delete All" button
|
||||||
|
const deleteAllBtns = container.querySelectorAll("[data-delete-all-index]");
|
||||||
|
deleteAllBtns.forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10);
|
||||||
|
app.handleFullDeleteVideo(idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading user videos:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal placeholder decryption for private videos.
|
||||||
|
*/
|
||||||
|
function fakeDecrypt(str) {
|
||||||
|
return str.split("").reverse().join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate older overshadowed versions – return only the newest for each root.
|
||||||
|
*/
|
||||||
|
function dedupeToNewestByRoot(videos) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const vid of videos) {
|
||||||
|
const rootId = vid.videoRootId || vid.id;
|
||||||
|
const existing = map.get(rootId);
|
||||||
|
if (!existing || vid.created_at > existing.created_at) {
|
||||||
|
map.set(rootId, vid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a raw Nostr event => "video" object.
|
||||||
|
*/
|
||||||
|
function localConvertEventToVideo(event) {
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(event.content || "{}");
|
||||||
|
const isSupportedVersion = content.version >= 2;
|
||||||
|
const hasRequiredFields = !!(content.title && content.magnet);
|
||||||
|
|
||||||
|
if (!isSupportedVersion) {
|
||||||
|
return { id: event.id, invalid: true, reason: "version <2" };
|
||||||
|
}
|
||||||
|
if (!hasRequiredFields) {
|
||||||
|
return { id: event.id, invalid: true, reason: "missing title/magnet" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
videoRootId: content.videoRootId || event.id,
|
||||||
|
version: content.version,
|
||||||
|
isPrivate: content.isPrivate ?? false,
|
||||||
|
title: content.title ?? "",
|
||||||
|
magnet: content.magnet ?? "",
|
||||||
|
thumbnail: content.thumbnail ?? "",
|
||||||
|
description: content.description ?? "",
|
||||||
|
mode: content.mode ?? "live",
|
||||||
|
deleted: content.deleted === true,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
created_at: event.created_at,
|
||||||
|
tags: event.tags,
|
||||||
|
invalid: false,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { id: event.id, invalid: true, reason: "json parse error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent injection or XSS.
|
||||||
|
*/
|
||||||
|
function escapeHTML(unsafe = "") {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
12
js/index.js
12
js/index.js
@@ -297,9 +297,14 @@ function handleQueryParams() {
|
|||||||
*/
|
*/
|
||||||
function handleHashChange() {
|
function handleHashChange() {
|
||||||
console.log("handleHashChange called, current hash =", window.location.hash);
|
console.log("handleHashChange called, current hash =", window.location.hash);
|
||||||
|
|
||||||
const hash = window.location.hash || "";
|
const hash = window.location.hash || "";
|
||||||
const match = hash.match(/^#view=(.+)/);
|
// Use a regex that captures up to the first ampersand or end of string.
|
||||||
|
// E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile"
|
||||||
|
const match = hash.match(/^#view=([^&]+)/);
|
||||||
|
|
||||||
if (!match || !match[1]) {
|
if (!match || !match[1]) {
|
||||||
|
// No valid "#view=..." => default to "most-recent-videos"
|
||||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||||
loadView("views/most-recent-videos.html").then(() => {
|
loadView("views/most-recent-videos.html").then(() => {
|
||||||
const initFn = viewInitRegistry["most-recent-videos"];
|
const initFn = viewInitRegistry["most-recent-videos"];
|
||||||
@@ -310,8 +315,11 @@ function handleHashChange() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const viewName = match[1];
|
|
||||||
|
const viewName = match[1]; // only the chunk before any '&'
|
||||||
const viewUrl = `views/${viewName}.html`;
|
const viewUrl = `views/${viewName}.html`;
|
||||||
|
|
||||||
|
// Now dynamically load that partial, then call its init function
|
||||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||||
loadView(viewUrl).then(() => {
|
loadView(viewUrl).then(() => {
|
||||||
const initFn = viewInitRegistry[viewName];
|
const initFn = viewInitRegistry[viewName];
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
// js/viewManager.js
|
// js/viewManager.js
|
||||||
|
import { initChannelProfileView } from "./channelProfile.js";
|
||||||
|
|
||||||
// Load a partial view by URL into the #viewContainer
|
/**
|
||||||
// js/viewManager.js
|
* Load a partial view by URL into the #viewContainer.
|
||||||
|
*/
|
||||||
export async function loadView(viewUrl) {
|
export async function loadView(viewUrl) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(viewUrl);
|
const res = await fetch(viewUrl);
|
||||||
@@ -10,14 +12,14 @@ export async function loadView(viewUrl) {
|
|||||||
}
|
}
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
|
||||||
// DOMParser, parse out the body, inject
|
// Use a DOMParser to extract the body contents
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(text, "text/html");
|
const doc = parser.parseFromString(text, "text/html");
|
||||||
const container = document.getElementById("viewContainer");
|
const container = document.getElementById("viewContainer");
|
||||||
|
|
||||||
container.innerHTML = doc.body.innerHTML;
|
container.innerHTML = doc.body.innerHTML;
|
||||||
|
|
||||||
// Now copy and execute each script
|
// Copy and execute any inline scripts
|
||||||
const scriptTags = doc.querySelectorAll("script");
|
const scriptTags = doc.querySelectorAll("script");
|
||||||
scriptTags.forEach((oldScript) => {
|
scriptTags.forEach((oldScript) => {
|
||||||
const newScript = document.createElement("script");
|
const newScript = document.createElement("script");
|
||||||
@@ -34,13 +36,16 @@ export async function loadView(viewUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of view-specific initialization functions.
|
||||||
|
*/
|
||||||
export const viewInitRegistry = {
|
export const viewInitRegistry = {
|
||||||
"most-recent-videos": () => {
|
"most-recent-videos": () => {
|
||||||
if (window.app && window.app.loadVideos) {
|
if (window.app && window.app.loadVideos) {
|
||||||
window.app.videoList = document.getElementById("videoList");
|
window.app.videoList = document.getElementById("videoList");
|
||||||
window.app.loadVideos();
|
window.app.loadVideos();
|
||||||
}
|
}
|
||||||
// Force the profiles to update after the new view is in place.
|
// Force profile updates after the new view is in place.
|
||||||
if (window.app && window.app.forceRefreshAllProfiles) {
|
if (window.app && window.app.forceRefreshAllProfiles) {
|
||||||
window.app.forceRefreshAllProfiles();
|
window.app.forceRefreshAllProfiles();
|
||||||
}
|
}
|
||||||
@@ -51,5 +56,8 @@ export const viewInitRegistry = {
|
|||||||
subscriptions: () => {
|
subscriptions: () => {
|
||||||
console.log("Subscriptions view loaded.");
|
console.log("Subscriptions view loaded.");
|
||||||
},
|
},
|
||||||
// Add additional view-specific functions here as needed.
|
"channel-profile": () => {
|
||||||
|
// Call the initialization function from channelProfile.js
|
||||||
|
initChannelProfileView();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
52
views/channel-profile.html
Normal file
52
views/channel-profile.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<section class="channel-profile-container">
|
||||||
|
<!-- Modified banner wrapper for taller, rounded style -->
|
||||||
|
<div class="profile-banner relative mb-6 rounded-lg overflow-hidden shadow-lg">
|
||||||
|
<!-- Increase the default height classes -->
|
||||||
|
<img
|
||||||
|
id="channelBanner"
|
||||||
|
class="w-full h-48 md:h-64 object-cover"
|
||||||
|
src=""
|
||||||
|
alt="Banner"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-50"></div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-4 left-4 flex items-center">
|
||||||
|
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
|
||||||
|
<img
|
||||||
|
id="channelAvatar"
|
||||||
|
src="assets/svg/default-profile.svg"
|
||||||
|
alt="Avatar"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 text-white">
|
||||||
|
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
|
||||||
|
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-details px-4 mt-4">
|
||||||
|
<p id="channelAbout" class="mb-2 text-gray-700"></p>
|
||||||
|
<a
|
||||||
|
id="channelWebsite"
|
||||||
|
href="#"
|
||||||
|
target="_blank"
|
||||||
|
class="block text-blue-500 hover:underline mb-2"
|
||||||
|
>Website</a
|
||||||
|
>
|
||||||
|
<p id="channelLightning" class="text-sm text-gray-500"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-6" />
|
||||||
|
|
||||||
|
<h3 class="text-lg font-bold mb-4 text-gray-700">Videos by This User</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="channelVideoList"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
|
||||||
|
>
|
||||||
|
<!-- User’s videos will go here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
Reference in New Issue
Block a user