mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-08 06:58:43 +00:00
Merge pull request #13 from PR0M3TH3AN/unstable
added profile/channel pages, subscription lists and channel subscription feed, removed "private" video flags
This commit is contained in:
11
assets/svg/feed-icon.svg
Normal file
11
assets/svg/feed-icon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="iconmonstr-weather-64.svg" transform="matrix(1,0,0,1,12,12)">
|
||||
<g id="iconmonstr-filter-down-lined.svg" transform="matrix(1,0,0,1,-12,-12)">
|
||||
<path d="M15.344,17.778C15.344,17.364 15.008,17.028 14.594,17.028L9.434,17.028C9.02,17.028 8.684,17.364 8.684,17.778C8.684,18.192 9.02,18.528 9.434,18.528L14.594,18.528C15.008,18.528 15.344,18.192 15.344,17.778ZM17.55,13.778C17.55,13.364 17.214,13.028 16.8,13.028L7.204,13.028C6.79,13.028 6.454,13.364 6.454,13.778C6.454,14.192 6.79,14.528 7.204,14.528L16.8,14.528C17.214,14.528 17.55,14.192 17.55,13.778ZM20,9.778C20,9.364 19.664,9.028 19.25,9.028L4.75,9.028C4.336,9.028 4,9.364 4,9.778C4,10.192 4.336,10.528 4.75,10.528L19.25,10.528C19.664,10.528 20,10.192 20,9.778ZM22,5.778C22,5.364 21.664,5.028 21.25,5.028L2.75,5.028C2.336,5.028 2,5.364 2,5.778C2,6.192 2.336,6.528 2.75,6.528L21.25,6.528C21.664,6.528 22,6.192 22,5.778Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g id="iconmonstr-filter-down-lined.svg1" serif:id="iconmonstr-filter-down-lined.svg">
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
16
assets/svg/hot-icon.svg
Normal file
16
assets/svg/hot-icon.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="iconmonstr-weather-64.svg" transform="matrix(1,0,0,1,12,12)">
|
||||
<g id="iconmonstr-whats-hot-1.svg" transform="matrix(1,0,0,1,-12,-12)">
|
||||
<clipPath id="_clip1">
|
||||
<rect x="0" y="0" width="24" height="24"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<path d="M8.625,0C9.235,7.189 3,9.664 3,15.996C3,20.297 6.069,23.968 12,24C17.931,24.032 21,19.586 21,15.044C21,10.903 18.938,6.998 15.048,4.57C15.972,7.177 14.742,9.558 13.547,10.378C13.617,7.041 12.422,2.089 8.625,0ZM13.336,13C17.091,16.989 14.785,22 11.769,22C9.934,22 8.99,20.735 9,19.423C9.019,16.99 11.737,16.988 13.336,13Z" style="fill:rgb(92,111,138);"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="iconmonstr-whats-hot-1.svg1" serif:id="iconmonstr-whats-hot-1.svg">
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
9
assets/svg/recent-icon.svg
Normal file
9
assets/svg/recent-icon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="iconmonstr-weather-64.svg" transform="matrix(1,0,0,1,12,12)">
|
||||
<g transform="matrix(1,0,0,1,-12,-12)">
|
||||
<path d="M11,10L0,10L0,8L11,8C11.552,8 12,7.552 12,7C12,6.448 11.552,6 11,6C10.597,6 10.253,6.242 10.095,6.587L8.346,5.631C8.845,4.666 9.84,4 11,4C14.971,4 14.969,10 11,10ZM18,17C18,15.344 16.656,14 15,14L0,14L0,16L15,16C15.552,16 16,16.448 16,17C16,17.552 15.552,18 15,18C14.597,18 14.253,17.758 14.095,17.413L12.346,18.369C12.845,19.334 13.84,20 15,20C16.656,20 18,18.656 18,17ZM19.014,9.345C19.096,8.592 19.726,8 20.5,8C21.327,8 22,8.673 22,9.5C22,10.327 21.327,11 20.5,11L0,11L0,13L20.5,13C22.432,13 24,11.432 24,9.5C24,7.568 22.432,6 20.5,6C18.876,6 17.523,7.116 17.128,8.617L19.014,9.345Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
5
assets/svg/subscribe-button-icon.svg
Normal file
5
assets/svg/subscribe-button-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="M21.698,10.658L24,12L11.998,19L0,12L2.301,10.658L11.998,16.316L21.698,10.658ZM11.998,21.315L2.301,15.657L0,17L11.998,24L24,17L21.698,15.658L11.998,21.315ZM24,7L11.998,-0L0,7L11.998,14L24,7Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
</svg>
|
After Width: | Height: | Size: 694 B |
@@ -25,28 +25,7 @@
|
||||
<!-- Sticky top bar, if you want a top heading or 'X' button up here -->
|
||||
<div
|
||||
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>
|
||||
|
||||
<!-- Main Content -->
|
||||
|
@@ -12,11 +12,11 @@
|
||||
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/home-icon.svg"
|
||||
alt="Home"
|
||||
src="assets/svg/recent-icon.svg"
|
||||
alt="Recent"
|
||||
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||
/>
|
||||
<span class="sidebar-text">Home</span>
|
||||
<span class="sidebar-text">Recent</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=explore"
|
||||
|
@@ -105,7 +105,7 @@
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -116,7 +116,7 @@
|
||||
>Private Listing (Encrypt Magnet)</span
|
||||
>
|
||||
</div>
|
||||
|
||||
-->
|
||||
<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"
|
||||
|
@@ -2,8 +2,31 @@
|
||||
|
||||
> ⚠️ **Note:** If you find a new bug thats not listed here. Please submit a [Bug Report](https://bitvid.network/?modal=bug).
|
||||
|
||||
- Speed up loading in subscriptions. Save to local cache?
|
||||
- Fix "Dev Mode" publishing "Live Mode" notes—add a flag for dev mode posts.
|
||||
- Fix issue with video post set to private.
|
||||
- Disable "private video" until I can make it work better.
|
||||
- Make "private video" work better using nip-04 encryption for magnet field.
|
||||
- Fix slow back button issues on Firefox.
|
||||
- Add Amber login support for mobile.
|
||||
- Add settings (three dots) button for all videos, not the same as gear menu. Only show delete and edit gear for logged-in users videos.
|
||||
- Add support for "Playlist" lists and other custom lists (named whatever) and also "Watch Later" list and "Watch History".
|
||||
- Add an "add to playlist" button to the edit button.
|
||||
- Add "add to watch later" option to edit button.
|
||||
- Add block button to settings button.
|
||||
- Add report button to settings button.
|
||||
- Fix autoplay broken on iPhone chrome.
|
||||
- Fix playback broken on Safari on iPhone.
|
||||
- Update note spec v3 transition to include new fields that have been added like "has previous" or "videoRootID" or enentID if null.
|
||||
- Change "explore" view to "kids" view and add flag to all notes to see if they are for kids.
|
||||
- Add "seed" lists.
|
||||
- Make "edit" video a form modal rather than a browser pop-up.
|
||||
- Add "upload thumbnail" as option to add/edit video form. (use https://apidocs.imgur.com/)
|
||||
- Fix issue where page refreshes when pulling up a video in the video modal.
|
||||
- Fix sidebar media query settings on medium-sized screens. (tablet/laptops)
|
||||
- Fix various "text wrap" issues causing scroll left and right on profile and modal pages.
|
||||
- Add zaps to profile and video modal pages.
|
||||
- Add comments to video modal pages.
|
||||
|
||||
## Feature Additions
|
||||
|
||||
|
@@ -40,7 +40,7 @@ header img {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Video Grid */
|
||||
/* Video Grids */
|
||||
#videoList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
@@ -48,6 +48,22 @@ header img {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Subscriptions grid: same pattern as #videoList */
|
||||
#subscriptionsVideoList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Now also match for channelVideoList (channel profile) */
|
||||
#channelVideoList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Video Cards */
|
||||
.video-card {
|
||||
background-color: var(--color-card);
|
||||
@@ -464,7 +480,6 @@ footer a:hover {
|
||||
padding-top: 56.25%;
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
.ratio-16-9 > img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
191
js/app.js
191
js/app.js
@@ -299,10 +299,8 @@ class bitvidApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After we load the video modal, store references in `this.*`.
|
||||
*/
|
||||
updateModalElements() {
|
||||
// Existing references
|
||||
this.playerModal = document.getElementById("playerModal") || null;
|
||||
this.modalVideo = document.getElementById("modalVideo") || null;
|
||||
this.modalStatus = document.getElementById("modalStatus") || null;
|
||||
@@ -315,20 +313,22 @@ class bitvidApp {
|
||||
this.videoTitle = document.getElementById("videoTitle") || null;
|
||||
this.videoDescription = document.getElementById("videoDescription") || null;
|
||||
this.videoTimestamp = document.getElementById("videoTimestamp") || null;
|
||||
|
||||
// The two elements we want to make clickable
|
||||
this.creatorAvatar = document.getElementById("creatorAvatar") || null;
|
||||
this.creatorName = document.getElementById("creatorName") || null;
|
||||
this.creatorNpub = document.getElementById("creatorNpub") || null;
|
||||
|
||||
// Copy/Share buttons
|
||||
this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || 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) {
|
||||
this.copyMagnetBtn.addEventListener("click", () => {
|
||||
this.handleCopyMagnet();
|
||||
});
|
||||
}
|
||||
|
||||
// UPDATED: This share button just copies the ?v= URL to the clipboard:
|
||||
if (this.shareBtn) {
|
||||
this.shareBtn.addEventListener("click", () => {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -658,31 +708,33 @@ class bitvidApp {
|
||||
async batchFetchProfiles(authorSet) {
|
||||
const pubkeys = Array.from(authorSet);
|
||||
if (!pubkeys.length) return;
|
||||
|
||||
|
||||
const filter = {
|
||||
kinds: [0],
|
||||
authors: pubkeys,
|
||||
limit: pubkeys.length,
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
// Query each relay
|
||||
const results = await Promise.all(
|
||||
nostrClient.relays.map(relayUrl =>
|
||||
nostrClient.relays.map((relayUrl) =>
|
||||
nostrClient.pool.list([relayUrl], [filter])
|
||||
)
|
||||
);
|
||||
const allProfileEvents = results.flat();
|
||||
|
||||
|
||||
// Keep only the newest per author
|
||||
const newestEvents = new Map();
|
||||
for (const evt of allProfileEvents) {
|
||||
if (!newestEvents.has(evt.pubkey) ||
|
||||
evt.created_at > newestEvents.get(evt.pubkey).created_at) {
|
||||
if (
|
||||
!newestEvents.has(evt.pubkey) ||
|
||||
evt.created_at > newestEvents.get(evt.pubkey).created_at
|
||||
) {
|
||||
newestEvents.set(evt.pubkey, evt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update the cache & DOM
|
||||
for (const [pubkey, evt] of newestEvents.entries()) {
|
||||
try {
|
||||
@@ -701,7 +753,7 @@ class bitvidApp {
|
||||
console.error("Batch profile fetch error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateProfileInDOM(pubkey, profile) {
|
||||
// For any .author-pic[data-pubkey=...]
|
||||
const picEls = document.querySelectorAll(
|
||||
@@ -741,7 +793,7 @@ class bitvidApp {
|
||||
thumbnail: thumbEl?.value.trim() || "",
|
||||
description: descEl?.value.trim() || "",
|
||||
mode: isDevMode ? "dev" : "live",
|
||||
isPrivate: privEl?.checked || false,
|
||||
// isPrivate: privEl?.checked || false,
|
||||
};
|
||||
|
||||
if (!formData.title || !formData.magnet) {
|
||||
@@ -973,10 +1025,10 @@ class bitvidApp {
|
||||
});
|
||||
|
||||
if (this.videoSubscription) {
|
||||
|
||||
console.log("[loadVideos] subscription remains open to get live updates.");
|
||||
console.log(
|
||||
"[loadVideos] subscription remains open to get live updates."
|
||||
);
|
||||
}
|
||||
|
||||
} else {
|
||||
// Already subscribed: just show what's cached
|
||||
const allCached = nostrClient.getActiveVideos();
|
||||
@@ -1005,12 +1057,12 @@ class bitvidApp {
|
||||
async loadOlderVideos(lastTimestamp) {
|
||||
// 1) Use nostrClient to fetch older slices
|
||||
const olderVideos = await nostrClient.fetchOlderVideos(lastTimestamp);
|
||||
|
||||
|
||||
if (!olderVideos || olderVideos.length === 0) {
|
||||
this.showSuccess("No more older videos found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 2) Merge them into the client’s allEvents / activeMap
|
||||
for (const v of olderVideos) {
|
||||
nostrClient.allEvents.set(v.id, v);
|
||||
@@ -1019,11 +1071,11 @@ class bitvidApp {
|
||||
// You can call getActiveKey(v) if you want to match your code’s approach.
|
||||
// Then re-check if this one is newer than what’s stored, etc.
|
||||
}
|
||||
|
||||
|
||||
// 3) Re-render
|
||||
const all = nostrClient.getActiveVideos();
|
||||
this.renderVideoList(all);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there's at least one strictly older version
|
||||
@@ -1044,8 +1096,8 @@ class bitvidApp {
|
||||
|
||||
async renderVideoList(videos) {
|
||||
if (!this.videoList) return;
|
||||
|
||||
// Check if there's anything to show
|
||||
|
||||
// 1) If no videos
|
||||
if (!videos || videos.length === 0) {
|
||||
this.videoList.innerHTML = `
|
||||
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
||||
@@ -1053,26 +1105,23 @@ class bitvidApp {
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
|
||||
// 2) Sort newest first
|
||||
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 fragment = document.createDocumentFragment();
|
||||
|
||||
// 1) Collect authors here so we can fetch profiles in one go
|
||||
const authorSet = new Set();
|
||||
|
||||
|
||||
// 3) Build each card
|
||||
videos.forEach((video, index) => {
|
||||
if (!video.id || !video.title) {
|
||||
console.error("Video missing ID/title:", video);
|
||||
return;
|
||||
}
|
||||
|
||||
// Track this author's pubkey for the batch fetch later
|
||||
|
||||
authorSet.add(video.pubkey);
|
||||
|
||||
|
||||
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
|
||||
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
|
||||
nevent
|
||||
@@ -1083,13 +1132,13 @@ class bitvidApp {
|
||||
? "border-2 border-yellow-500"
|
||||
: "border-none";
|
||||
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;
|
||||
if (canEdit && video.videoRootId) {
|
||||
hasOlder = this.hasOlderVersion(video, fullAllEventsArray);
|
||||
}
|
||||
|
||||
|
||||
const revertButton = hasOlder
|
||||
? `
|
||||
<button
|
||||
@@ -1100,8 +1149,7 @@ class bitvidApp {
|
||||
</button>
|
||||
`
|
||||
: "";
|
||||
|
||||
// Gear menu (only shown if canEdit)
|
||||
|
||||
const gearMenu = canEdit
|
||||
? `
|
||||
<div class="relative inline-block ml-3 overflow-visible">
|
||||
@@ -1139,10 +1187,10 @@ class bitvidApp {
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
// Card markup
|
||||
|
||||
const cardHtml = `
|
||||
<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
|
||||
href="${shareUrl}"
|
||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||
@@ -1157,6 +1205,7 @@ class bitvidApp {
|
||||
</div>
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<!-- Title triggers the video modal as well -->
|
||||
<h3
|
||||
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||
@@ -1182,6 +1231,7 @@ class bitvidApp {
|
||||
</p>
|
||||
<div class="flex items-center text-xs text-gray-500 mt-1">
|
||||
<span>${timeAgo}</span>
|
||||
<!-- We removed the 'Channel' button here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1190,28 +1240,22 @@ class bitvidApp {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Turn the HTML into an element
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = cardHtml.trim();
|
||||
const cardEl = template.content.firstElementChild;
|
||||
|
||||
fragment.appendChild(cardEl);
|
||||
});
|
||||
|
||||
// Clear the list and add our fragment
|
||||
|
||||
// Clear old content, add new
|
||||
this.videoList.innerHTML = "";
|
||||
this.videoList.appendChild(fragment);
|
||||
|
||||
|
||||
// Lazy-load images
|
||||
const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
|
||||
lazyEls.forEach((el) => this.mediaLoader.observe(el));
|
||||
|
||||
// -------------------------------
|
||||
// Gear menu / button event listeners
|
||||
// -------------------------------
|
||||
|
||||
// Toggle the gear menu
|
||||
|
||||
// GEAR MENU / button event listeners...
|
||||
const gearButtons = this.videoList.querySelectorAll(
|
||||
"[data-settings-dropdown]"
|
||||
);
|
||||
@@ -1224,7 +1268,7 @@ class bitvidApp {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Edit button
|
||||
const editButtons = this.videoList.querySelectorAll("[data-edit-index]");
|
||||
editButtons.forEach((button) => {
|
||||
@@ -1235,9 +1279,11 @@ class bitvidApp {
|
||||
this.handleEditVideo(index);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Revert button
|
||||
const revertButtons = this.videoList.querySelectorAll("[data-revert-index]");
|
||||
const revertButtons = this.videoList.querySelectorAll(
|
||||
"[data-revert-index]"
|
||||
);
|
||||
revertButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const index = button.getAttribute("data-revert-index");
|
||||
@@ -1246,7 +1292,7 @@ class bitvidApp {
|
||||
this.handleRevertVideo(index);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Delete All button
|
||||
const deleteAllButtons = this.videoList.querySelectorAll(
|
||||
"[data-delete-all-index]"
|
||||
@@ -1259,11 +1305,34 @@ class bitvidApp {
|
||||
this.handleFullDeleteVideo(index);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// 2) After building cards, do one batch profile fetch
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the modal to reflect current torrent stats.
|
||||
* We remove the unused torrent.status references,
|
||||
@@ -1357,7 +1426,7 @@ class bitvidApp {
|
||||
"New Description? (blank=keep existing)",
|
||||
video.description
|
||||
);
|
||||
const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No");
|
||||
// const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No");
|
||||
|
||||
// 4) Build final updated fields (or fallback to existing)
|
||||
const title =
|
||||
@@ -1372,7 +1441,7 @@ class bitvidApp {
|
||||
// 5) Create an object with the new data
|
||||
const updatedData = {
|
||||
version: video.version || 2,
|
||||
isPrivate: wantPrivate,
|
||||
// isPrivate: wantPrivate,
|
||||
title,
|
||||
magnet,
|
||||
thumbnail,
|
||||
|
496
js/channelProfile.js
Normal file
496
js/channelProfile.js
Normal file
@@ -0,0 +1,496 @@
|
||||
// js/channelProfile.js
|
||||
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import { app } from "./app.js";
|
||||
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
|
||||
import { initialBlacklist, initialWhitelist } from "./lists.js";
|
||||
import { isWhitelistEnabled } from "./config.js";
|
||||
|
||||
/**
|
||||
* Initialize the channel profile view.
|
||||
* Called when #view=channel-profile&npub=...
|
||||
*/
|
||||
export async function initChannelProfileView() {
|
||||
// 1) Get npub from hash
|
||||
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const npub = hashParams.get("npub");
|
||||
if (!npub) {
|
||||
console.error(
|
||||
"No npub found in hash (e.g. #view=channel-profile&npub=...)"
|
||||
);
|
||||
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) If user is logged in, load subscriptions and show sub/unsub button
|
||||
if (app.pubkey) {
|
||||
await subscriptions.loadSubscriptions(app.pubkey);
|
||||
renderSubscribeButton(hexPub);
|
||||
} else {
|
||||
const btn = document.getElementById("subscribeBtnArea");
|
||||
if (btn) btn.classList.add("hidden");
|
||||
}
|
||||
|
||||
// 4) Load user’s profile (banner, avatar, etc.)
|
||||
await loadUserProfile(hexPub);
|
||||
|
||||
// 5) Load user’s videos (filtered + rendered like the home feed)
|
||||
await loadUserVideos(hexPub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Subscribe / Unsubscribe button with an icon,
|
||||
* using color #fe0032 and the subscribe-button-icon.svg on the left.
|
||||
*/
|
||||
function renderSubscribeButton(channelHex) {
|
||||
const container = document.getElementById("subscribeBtnArea");
|
||||
if (!container) return;
|
||||
|
||||
container.classList.remove("hidden");
|
||||
const alreadySubscribed = subscriptions.isSubscribed(channelHex);
|
||||
|
||||
// We'll use #fe0032 for both subscribe/unsubscribe,
|
||||
// and the same icon. If you prefer separate logic for unsub, you can do it here.
|
||||
container.innerHTML = `
|
||||
<button
|
||||
id="subscribeToggleBtn"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded text-white
|
||||
hover:opacity-90 focus:outline-none"
|
||||
style="background-color: #fe0032;"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/subscribe-button-icon.svg"
|
||||
alt="Subscribe Icon"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
<span>${alreadySubscribed ? "Unsubscribe" : "Subscribe"}</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const toggleBtn = document.getElementById("subscribeToggleBtn");
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener("click", async () => {
|
||||
if (!app.pubkey) {
|
||||
console.error("Not logged in => cannot subscribe/unsubscribe.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (alreadySubscribed) {
|
||||
await subscriptions.removeChannel(channelHex, app.pubkey);
|
||||
} else {
|
||||
await subscriptions.addChannel(channelHex, app.pubkey);
|
||||
}
|
||||
// Re-render the button so it toggles state
|
||||
renderSubscribeButton(channelHex);
|
||||
} catch (err) {
|
||||
console.error("Failed to update subscription:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Filters out older overshadowed notes, blacklisted, etc.
|
||||
*/
|
||||
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 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 IDs / authors
|
||||
videos = videos.filter((video) => {
|
||||
// Event-level blacklisting
|
||||
if (app.blacklistedEventIds.has(video.id)) return false;
|
||||
|
||||
// Author-level
|
||||
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 newest first
|
||||
videos.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
// 7) Render them
|
||||
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();
|
||||
const allKnownEventsArray = Array.from(nostrClient.allEvents.values());
|
||||
|
||||
videos.forEach((video, index) => {
|
||||
// Decrypt if user owns a private video
|
||||
if (
|
||||
video.isPrivate &&
|
||||
video.pubkey === nostrClient.pubkey &&
|
||||
!video.alreadyDecrypted
|
||||
) {
|
||||
video.magnet = fakeDecrypt(video.magnet);
|
||||
video.alreadyDecrypted = true;
|
||||
}
|
||||
|
||||
// Check if user can edit
|
||||
const canEdit = video.pubkey === app.pubkey;
|
||||
let hasOlder = false;
|
||||
if (canEdit && video.videoRootId) {
|
||||
hasOlder = app.hasOlderVersion(video, allKnownEventsArray);
|
||||
}
|
||||
|
||||
const revertButton = hasOlder
|
||||
? `
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
||||
hover:bg-red-700 hover:text-white"
|
||||
data-revert-index="${index}"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
`
|
||||
: "";
|
||||
|
||||
let gearMenu = "";
|
||||
if (canEdit) {
|
||||
gearMenu = `
|
||||
<div class="relative inline-block ml-3 overflow-visible">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center
|
||||
w-10 h-10 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 object-contain"
|
||||
/>
|
||||
</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>
|
||||
${revertButton}
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
// Fallback thumbnail
|
||||
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
|
||||
const safeThumb = video.thumbnail || fallbackThumb;
|
||||
const safeTitle = escapeHTML(video.title);
|
||||
|
||||
const cardEl = document.createElement("div");
|
||||
cardEl.classList.add(
|
||||
"bg-gray-900",
|
||||
"rounded-lg",
|
||||
"overflow-hidden",
|
||||
"shadow-lg",
|
||||
"hover:shadow-2xl",
|
||||
"transition-all",
|
||||
"duration-300"
|
||||
);
|
||||
|
||||
cardEl.innerHTML = `
|
||||
<div class="cursor-pointer relative group">
|
||||
<div class="ratio-16-9">
|
||||
<img
|
||||
src="${fallbackThumb}"
|
||||
data-lazy="${escapeHTML(safeThumb)}"
|
||||
alt="${safeTitle}"
|
||||
/>
|
||||
</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)}"
|
||||
>
|
||||
${safeTitle}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
${new Date(video.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
${gearMenu}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Clicking the card => open the video modal
|
||||
cardEl.addEventListener("click", () => {
|
||||
app.playVideoByEventId(video.id);
|
||||
});
|
||||
|
||||
fragment.appendChild(cardEl);
|
||||
});
|
||||
|
||||
container.appendChild(fragment);
|
||||
|
||||
// Lazy-load images
|
||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
||||
|
||||
// Gear menu toggles
|
||||
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 handler
|
||||
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);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
app.handleEditVideo(idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Revert handler
|
||||
const revertBtns = container.querySelectorAll("[data-revert-index]");
|
||||
revertBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(btn.getAttribute("data-revert-index"), 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
app.handleRevertVideo(idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete All handler
|
||||
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);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
app.handleFullDeleteVideo(idx);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error loading user videos:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal placeholder for private video decryption.
|
||||
*/
|
||||
function fakeDecrypt(str) {
|
||||
return str.split("").reverse().join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the newest version of each video 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 raw 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" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic escaping to avoid 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() {
|
||||
console.log("handleHashChange called, current 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]) {
|
||||
// No valid "#view=..." => default to "most-recent-videos"
|
||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||
loadView("views/most-recent-videos.html").then(() => {
|
||||
const initFn = viewInitRegistry["most-recent-videos"];
|
||||
@@ -310,8 +315,11 @@ function handleHashChange() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const viewName = match[1];
|
||||
|
||||
const viewName = match[1]; // only the chunk before any '&'
|
||||
const viewUrl = `views/${viewName}.html`;
|
||||
|
||||
// Now dynamically load that partial, then call its init function
|
||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||
loadView(viewUrl).then(() => {
|
||||
const initFn = viewInitRegistry[viewName];
|
||||
|
@@ -12,6 +12,7 @@ const npubs = [
|
||||
"npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro
|
||||
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
|
||||
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
|
||||
"npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox
|
||||
];
|
||||
|
||||
console.log("DEBUG: lists.js loaded, npubs:", npubs);
|
||||
|
523
js/subscriptions.js
Normal file
523
js/subscriptions.js
Normal file
@@ -0,0 +1,523 @@
|
||||
// js/subscriptions.js
|
||||
import { nostrClient } from "./nostr.js";
|
||||
|
||||
/**
|
||||
* Manages the user's subscription list (kind=30002) *privately*,
|
||||
* using NIP-04 encryption for the content field.
|
||||
* Also handles fetching and rendering subscribed channels' videos
|
||||
* in the same card style as your home page.
|
||||
*/
|
||||
class SubscriptionsManager {
|
||||
constructor() {
|
||||
this.subscribedPubkeys = new Set();
|
||||
this.subsEventId = null;
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the subscription list from kind=30002 (d="subscriptions").
|
||||
*/
|
||||
async loadSubscriptions(userPubkey) {
|
||||
if (!userPubkey) {
|
||||
console.warn("[SubscriptionsManager] No pubkey => cannot load subs.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const filter = {
|
||||
kinds: [30002],
|
||||
authors: [userPubkey],
|
||||
"#d": ["subscriptions"],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events = [];
|
||||
for (const url of nostrClient.relays) {
|
||||
try {
|
||||
const result = await nostrClient.pool.list([url], [filter]);
|
||||
if (result && result.length) {
|
||||
events.push(...result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[SubscriptionsManager] Relay error at ${url}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
this.subscribedPubkeys.clear();
|
||||
this.subsEventId = null;
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by created_at desc, pick newest
|
||||
events.sort((a, b) => b.created_at - a.created_at);
|
||||
const newest = events[0];
|
||||
this.subsEventId = newest.id;
|
||||
|
||||
let decryptedStr = "";
|
||||
try {
|
||||
decryptedStr = await window.nostr.nip04.decrypt(
|
||||
userPubkey,
|
||||
newest.content
|
||||
);
|
||||
} catch (errDecrypt) {
|
||||
console.error("[SubscriptionsManager] Decryption failed:", errDecrypt);
|
||||
this.subscribedPubkeys.clear();
|
||||
this.subsEventId = null;
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(decryptedStr);
|
||||
const subArray = Array.isArray(parsed.subPubkeys)
|
||||
? parsed.subPubkeys
|
||||
: [];
|
||||
this.subscribedPubkeys = new Set(subArray);
|
||||
|
||||
this.loaded = true;
|
||||
} catch (err) {
|
||||
console.error("[SubscriptionsManager] Failed to load subs:", err);
|
||||
}
|
||||
}
|
||||
|
||||
isSubscribed(channelHex) {
|
||||
return this.subscribedPubkeys.has(channelHex);
|
||||
}
|
||||
|
||||
async addChannel(channelHex, userPubkey) {
|
||||
if (!userPubkey) {
|
||||
throw new Error("No user pubkey => cannot addChannel.");
|
||||
}
|
||||
if (this.subscribedPubkeys.has(channelHex)) {
|
||||
console.log("Already subscribed to", channelHex);
|
||||
return;
|
||||
}
|
||||
this.subscribedPubkeys.add(channelHex);
|
||||
await this.publishSubscriptionList(userPubkey);
|
||||
}
|
||||
|
||||
async removeChannel(channelHex, userPubkey) {
|
||||
if (!userPubkey) {
|
||||
throw new Error("No user pubkey => cannot removeChannel.");
|
||||
}
|
||||
if (!this.subscribedPubkeys.has(channelHex)) {
|
||||
console.log("Channel not found in subscription list:", channelHex);
|
||||
return;
|
||||
}
|
||||
this.subscribedPubkeys.delete(channelHex);
|
||||
await this.publishSubscriptionList(userPubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt (NIP-04) + publish the updated subscription set
|
||||
* as kind=30002 with ["d", "subscriptions"] to be replaceable.
|
||||
*/
|
||||
async publishSubscriptionList(userPubkey) {
|
||||
if (!userPubkey) {
|
||||
throw new Error("No pubkey => cannot publish subscription list.");
|
||||
}
|
||||
|
||||
const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
|
||||
const plainStr = JSON.stringify(plainObj);
|
||||
|
||||
let cipherText = "";
|
||||
try {
|
||||
cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
|
||||
} catch (err) {
|
||||
console.error("Encryption failed:", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const evt = {
|
||||
kind: 30002,
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["d", "subscriptions"]],
|
||||
content: cipherText,
|
||||
};
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(evt);
|
||||
await Promise.all(
|
||||
nostrClient.relays.map(async (relay) => {
|
||||
try {
|
||||
await nostrClient.pool.publish([relay], signedEvent);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[SubscriptionsManager] Failed to publish to ${relay}`,
|
||||
e
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.subsEventId = signedEvent.id;
|
||||
console.log("Subscription list published, event id:", signedEvent.id);
|
||||
} catch (signErr) {
|
||||
console.error("Failed to sign/publish subscription list:", signErr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If not loaded, load subs, then fetch + render videos
|
||||
* in #subscriptionsVideoList with the same style as app.renderVideoList.
|
||||
*/
|
||||
async showSubscriptionVideos(
|
||||
userPubkey,
|
||||
containerId = "subscriptionsVideoList"
|
||||
) {
|
||||
if (!userPubkey) {
|
||||
const c = document.getElementById(containerId);
|
||||
if (c) c.innerHTML = "<p class='text-gray-500'>Please log in first.</p>";
|
||||
return;
|
||||
}
|
||||
if (!this.loaded) {
|
||||
await this.loadSubscriptions(userPubkey);
|
||||
}
|
||||
|
||||
const channelHexes = Array.from(this.subscribedPubkeys);
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
if (!channelHexes.length) {
|
||||
container.innerHTML =
|
||||
"<p class='text-gray-500'>No subscriptions found.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather all videos
|
||||
const videos = await this.fetchSubscribedVideos(channelHexes);
|
||||
this.renderSameGridStyle(videos, containerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull all events from subscribed authors, convert, dedupe => newest
|
||||
*/
|
||||
async fetchSubscribedVideos(authorPubkeys) {
|
||||
try {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
authors: authorPubkeys,
|
||||
limit: 500,
|
||||
};
|
||||
|
||||
const allEvents = [];
|
||||
for (const relay of nostrClient.relays) {
|
||||
try {
|
||||
const res = await nostrClient.pool.list([relay], [filter]);
|
||||
allEvents.push(...res);
|
||||
} catch (rErr) {
|
||||
console.error(`[SubscriptionsManager] Error at ${relay}`, rErr);
|
||||
}
|
||||
}
|
||||
|
||||
const videos = [];
|
||||
for (const evt of allEvents) {
|
||||
const vid = this.convertEventToVideo(evt);
|
||||
if (!vid.invalid && !vid.deleted) videos.push(vid);
|
||||
}
|
||||
|
||||
const deduped = this.dedupeToNewestByRoot(videos);
|
||||
deduped.sort((a, b) => b.created_at - a.created_at);
|
||||
return deduped;
|
||||
} catch (err) {
|
||||
console.error("fetchSubscribedVideos error:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the feed in the same style as home.
|
||||
* This includes gear menu, time-ago, lazy load, clickable authors, etc.
|
||||
*/
|
||||
renderSameGridStyle(videos, containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
if (!videos.length) {
|
||||
container.innerHTML = `
|
||||
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
||||
No videos available yet.
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
videos.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
|
||||
const fragment = document.createDocumentFragment();
|
||||
// Only declare localAuthorSet once
|
||||
const localAuthorSet = new Set();
|
||||
|
||||
videos.forEach((video, index) => {
|
||||
if (!video.id || !video.title) {
|
||||
console.error("Missing ID or title:", video);
|
||||
return;
|
||||
}
|
||||
|
||||
localAuthorSet.add(video.pubkey);
|
||||
|
||||
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
|
||||
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
|
||||
nevent
|
||||
)}`;
|
||||
const canEdit = window.app?.pubkey === video.pubkey;
|
||||
|
||||
const highlightClass =
|
||||
video.isPrivate && canEdit
|
||||
? "border-2 border-yellow-500"
|
||||
: "border-none";
|
||||
|
||||
const timeAgo = window.app?.formatTimeAgo
|
||||
? window.app.formatTimeAgo(video.created_at)
|
||||
: new Date(video.created_at * 1000).toLocaleString();
|
||||
|
||||
let hasOlder = false;
|
||||
if (canEdit && video.videoRootId && window.app?.hasOlderVersion) {
|
||||
hasOlder = window.app.hasOlderVersion(video, fullAllEventsArray);
|
||||
}
|
||||
|
||||
const revertButton = hasOlder
|
||||
? `
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-revert-index="${index}"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
`
|
||||
: "";
|
||||
|
||||
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"
|
||||
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>
|
||||
${revertButton}
|
||||
<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>
|
||||
`
|
||||
: "";
|
||||
|
||||
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
|
||||
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
|
||||
const cardHtml = `
|
||||
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
||||
<a
|
||||
href="${shareUrl}"
|
||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||
class="block cursor-pointer relative group"
|
||||
>
|
||||
<div class="ratio-16-9">
|
||||
<img
|
||||
src="assets/jpg/video-thumbnail-fallback.jpg"
|
||||
data-lazy="${safeThumb}"
|
||||
alt="${safeTitle}"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<h3
|
||||
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||
>
|
||||
${safeTitle}
|
||||
</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden flex items-center justify-center">
|
||||
<img
|
||||
class="author-pic"
|
||||
data-pubkey="${video.pubkey}"
|
||||
src="assets/svg/default-profile.svg"
|
||||
alt="Placeholder"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="text-sm text-gray-400 author-name"
|
||||
data-pubkey="${video.pubkey}"
|
||||
>
|
||||
Loading name...
|
||||
</p>
|
||||
<div class="flex items-center text-xs text-gray-500 mt-1">
|
||||
<span>${timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${gearMenu}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const t = document.createElement("template");
|
||||
t.innerHTML = cardHtml.trim();
|
||||
const cardEl = t.content.firstElementChild;
|
||||
fragment.appendChild(cardEl);
|
||||
});
|
||||
|
||||
container.appendChild(fragment);
|
||||
|
||||
// Lazy-load
|
||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||
lazyEls.forEach((el) => window.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 editButtons = container.querySelectorAll("[data-edit-index]");
|
||||
editButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = btn.getAttribute("data-edit-index");
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
window.app?.handleEditVideo(idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Revert
|
||||
const revertButtons = container.querySelectorAll("[data-revert-index]");
|
||||
revertButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = btn.getAttribute("data-revert-index");
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
window.app?.handleRevertVideo(idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete All
|
||||
const deleteAllButtons = container.querySelectorAll(
|
||||
"[data-delete-all-index]"
|
||||
);
|
||||
deleteAllButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = btn.getAttribute("data-delete-all-index");
|
||||
const dd = document.getElementById(`settingsDropdown-${idx}`);
|
||||
if (dd) dd.classList.add("hidden");
|
||||
window.app?.handleFullDeleteVideo(idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Now fetch author profiles
|
||||
const authorPics = container.querySelectorAll(".author-pic");
|
||||
const authorNames = container.querySelectorAll(".author-name");
|
||||
|
||||
// We only declare localAuthorSet once at the top
|
||||
// so we don't cause a "duplicate" variable error.
|
||||
authorPics.forEach((pic) => {
|
||||
localAuthorSet.add(pic.getAttribute("data-pubkey"));
|
||||
});
|
||||
authorNames.forEach((nameEl) => {
|
||||
localAuthorSet.add(nameEl.getAttribute("data-pubkey"));
|
||||
});
|
||||
|
||||
if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) {
|
||||
window.app.batchFetchProfiles(localAuthorSet);
|
||||
}
|
||||
|
||||
// Make author name/pic clickable => open channel
|
||||
authorPics.forEach((pic) => {
|
||||
pic.style.cursor = "pointer";
|
||||
pic.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const pubkey = pic.getAttribute("data-pubkey");
|
||||
window.app?.goToProfile(pubkey);
|
||||
});
|
||||
});
|
||||
|
||||
authorNames.forEach((nameEl) => {
|
||||
nameEl.style.cursor = "pointer";
|
||||
nameEl.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const pubkey = nameEl.getAttribute("data-pubkey");
|
||||
window.app?.goToProfile(pubkey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
convertEventToVideo(evt) {
|
||||
try {
|
||||
const content = JSON.parse(evt.content || "{}");
|
||||
const hasFields = !!(content.title && content.magnet);
|
||||
const versionOk = content.version >= 2;
|
||||
if (!versionOk || !hasFields) {
|
||||
return { id: evt.id, invalid: true };
|
||||
}
|
||||
return {
|
||||
id: evt.id,
|
||||
pubkey: evt.pubkey,
|
||||
created_at: evt.created_at,
|
||||
videoRootId: content.videoRootId || evt.id,
|
||||
version: content.version,
|
||||
deleted: content.deleted === true,
|
||||
isPrivate: content.isPrivate === true,
|
||||
title: content.title || "",
|
||||
magnet: content.magnet || "",
|
||||
thumbnail: content.thumbnail || "",
|
||||
description: content.description || "",
|
||||
tags: evt.tags || [],
|
||||
invalid: false,
|
||||
};
|
||||
} catch (err) {
|
||||
return { id: evt.id, invalid: true };
|
||||
}
|
||||
}
|
||||
|
||||
dedupeToNewestByRoot(videos) {
|
||||
const map = new Map();
|
||||
for (const v of videos) {
|
||||
const rootId = v.videoRootId || v.id;
|
||||
const existing = map.get(rootId);
|
||||
if (!existing || v.created_at > existing.created_at) {
|
||||
map.set(rootId, v);
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptions = new SubscriptionsManager();
|
@@ -1,7 +1,10 @@
|
||||
// js/viewManager.js
|
||||
import { initChannelProfileView } from "./channelProfile.js";
|
||||
import { subscriptions } from "./subscriptions.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) {
|
||||
try {
|
||||
const res = await fetch(viewUrl);
|
||||
@@ -10,14 +13,14 @@ export async function loadView(viewUrl) {
|
||||
}
|
||||
const text = await res.text();
|
||||
|
||||
// DOMParser, parse out the body, inject
|
||||
// Use a DOMParser to extract the body contents
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(text, "text/html");
|
||||
const container = document.getElementById("viewContainer");
|
||||
|
||||
container.innerHTML = doc.body.innerHTML;
|
||||
|
||||
// Now copy and execute each script
|
||||
// Copy and execute any inline scripts
|
||||
const scriptTags = doc.querySelectorAll("script");
|
||||
scriptTags.forEach((oldScript) => {
|
||||
const newScript = document.createElement("script");
|
||||
@@ -34,13 +37,16 @@ export async function loadView(viewUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of view-specific initialization functions.
|
||||
*/
|
||||
export const viewInitRegistry = {
|
||||
"most-recent-videos": () => {
|
||||
if (window.app && window.app.loadVideos) {
|
||||
window.app.videoList = document.getElementById("videoList");
|
||||
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) {
|
||||
window.app.forceRefreshAllProfiles();
|
||||
}
|
||||
@@ -48,8 +54,33 @@ export const viewInitRegistry = {
|
||||
explore: () => {
|
||||
console.log("Explore view loaded.");
|
||||
},
|
||||
subscriptions: () => {
|
||||
|
||||
/**
|
||||
* Subscriptions view:
|
||||
* - If user is logged in, calls subscriptions.showSubscriptionVideos
|
||||
* which loads subs if needed and renders the video grid in #subscriptionsVideoList
|
||||
*/
|
||||
subscriptions: async () => {
|
||||
console.log("Subscriptions view loaded.");
|
||||
|
||||
if (!window.app.pubkey) {
|
||||
const container = document.getElementById("subscriptionsVideoList");
|
||||
if (container) {
|
||||
container.innerHTML =
|
||||
"<p class='text-gray-500'>Please log in to see your subscriptions.</p>";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If user is logged in, let the SubscriptionsManager do everything:
|
||||
await subscriptions.showSubscriptionVideos(
|
||||
window.app.pubkey,
|
||||
"subscriptionsVideoList"
|
||||
);
|
||||
},
|
||||
|
||||
"channel-profile": () => {
|
||||
// Call the initialization function from channelProfile.js
|
||||
initChannelProfileView();
|
||||
},
|
||||
// Add additional view-specific functions here as needed.
|
||||
};
|
||||
|
58
views/channel-profile.html
Normal file
58
views/channel-profile.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<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>
|
||||
|
||||
<!-- Basic profile details: about text, website, LN address -->
|
||||
<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>
|
||||
|
||||
<!-- Area for Subscribe/Unsubscribe button -->
|
||||
<div class="px-4 my-4">
|
||||
<div id="subscribeBtnArea" class="hidden"></div>
|
||||
</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>
|
@@ -1,5 +1,13 @@
|
||||
<!-- views/subscriptions.html -->
|
||||
<section>
|
||||
<h2 class="text-xl mb-4 text-gray-700">Subscriptions</h2>
|
||||
<p class="text-gray-600">Coming Soon...</p>
|
||||
<h2 class="text-xl mb-4">Subscriptions</h2>
|
||||
<div
|
||||
id="subscriptionsVideoList"
|
||||
style="
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
"
|
||||
></div>
|
||||
</section>
|
||||
|
Reference in New Issue
Block a user