mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-08 06:58:43 +00:00
added subscriptions
This commit is contained in:
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 |
@@ -40,7 +40,7 @@ header img {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Video Grid */
|
/* Video Grids */
|
||||||
#videoList {
|
#videoList {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
@@ -48,6 +48,22 @@ header img {
|
|||||||
padding: 1.5rem 0;
|
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 Cards */
|
||||||
.video-card {
|
.video-card {
|
||||||
background-color: var(--color-card);
|
background-color: var(--color-card);
|
||||||
@@ -525,4 +541,3 @@ footer a:hover {
|
|||||||
#sidebar hr {
|
#sidebar hr {
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2,20 +2,21 @@
|
|||||||
|
|
||||||
import { nostrClient } from "./nostr.js";
|
import { nostrClient } from "./nostr.js";
|
||||||
import { app } from "./app.js";
|
import { app } from "./app.js";
|
||||||
|
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
|
||||||
import { initialBlacklist, initialWhitelist } from "./lists.js";
|
import { initialBlacklist, initialWhitelist } from "./lists.js";
|
||||||
import { isWhitelistEnabled } from "./config.js";
|
import { isWhitelistEnabled } from "./config.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the channel profile view.
|
* Initialize the channel profile view.
|
||||||
* Called when #view=channel-profile is active.
|
* Called when #view=channel-profile&npub=...
|
||||||
*/
|
*/
|
||||||
export async function initChannelProfileView() {
|
export async function initChannelProfileView() {
|
||||||
// 1) Get npub from hash (e.g. #view=channel-profile&npub=...)
|
// 1) Get npub from hash
|
||||||
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
||||||
const npub = hashParams.get("npub");
|
const npub = hashParams.get("npub");
|
||||||
if (!npub) {
|
if (!npub) {
|
||||||
console.error(
|
console.error(
|
||||||
"No npub found in hash. Example: #view=channel-profile&npub=npub1..."
|
"No npub found in hash (e.g. #view=channel-profile&npub=...)"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -34,15 +35,75 @@ export async function initChannelProfileView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Load user’s profile (banner, avatar, etc.)
|
// 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);
|
await loadUserProfile(hexPub);
|
||||||
|
|
||||||
// 4) Load user’s videos (filtered + rendered like the home feed)
|
// 5) Load user’s videos (filtered + rendered like the home feed)
|
||||||
await loadUserVideos(hexPub);
|
await loadUserVideos(hexPub);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and displays the user’s metadata (kind=0).
|
* 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) {
|
async function loadUserProfile(pubkey) {
|
||||||
try {
|
try {
|
||||||
@@ -111,8 +172,8 @@ async function loadUserProfile(pubkey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and displays this user's videos (kind=30078),
|
* Fetches and displays this user's videos (kind=30078).
|
||||||
* filtering out older overshadowed notes, blacklisted, non‐whitelisted, etc.
|
* Filters out older overshadowed notes, blacklisted, etc.
|
||||||
*/
|
*/
|
||||||
async function loadUserVideos(pubkey) {
|
async function loadUserVideos(pubkey) {
|
||||||
try {
|
try {
|
||||||
@@ -177,13 +238,10 @@ async function loadUserVideos(pubkey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
const channelVideos = videos;
|
|
||||||
|
|
||||||
// We'll need all known events for revert-check
|
|
||||||
const allKnownEventsArray = Array.from(nostrClient.allEvents.values());
|
const allKnownEventsArray = Array.from(nostrClient.allEvents.values());
|
||||||
|
|
||||||
channelVideos.forEach((video, index) => {
|
videos.forEach((video, index) => {
|
||||||
// Private => decrypt if owned by the user
|
// Decrypt if user owns a private video
|
||||||
if (
|
if (
|
||||||
video.isPrivate &&
|
video.isPrivate &&
|
||||||
video.pubkey === nostrClient.pubkey &&
|
video.pubkey === nostrClient.pubkey &&
|
||||||
@@ -197,15 +255,13 @@ async function loadUserVideos(pubkey) {
|
|||||||
const canEdit = video.pubkey === app.pubkey;
|
const canEdit = video.pubkey === app.pubkey;
|
||||||
let hasOlder = false;
|
let hasOlder = false;
|
||||||
if (canEdit && video.videoRootId) {
|
if (canEdit && video.videoRootId) {
|
||||||
// Use the same hasOlderVersion approach as home feed
|
|
||||||
hasOlder = app.hasOlderVersion(video, allKnownEventsArray);
|
hasOlder = app.hasOlderVersion(video, allKnownEventsArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there's an older overshadowed version, show revert
|
|
||||||
const revertButton = hasOlder
|
const revertButton = hasOlder
|
||||||
? `
|
? `
|
||||||
<button
|
<button
|
||||||
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
||||||
hover:bg-red-700 hover:text-white"
|
hover:bg-red-700 hover:text-white"
|
||||||
data-revert-index="${index}"
|
data-revert-index="${index}"
|
||||||
>
|
>
|
||||||
@@ -214,15 +270,14 @@ async function loadUserVideos(pubkey) {
|
|||||||
`
|
`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Gear menu
|
|
||||||
let gearMenu = "";
|
let gearMenu = "";
|
||||||
if (canEdit) {
|
if (canEdit) {
|
||||||
gearMenu = `
|
gearMenu = `
|
||||||
<div class="relative inline-block ml-3 overflow-visible">
|
<div class="relative inline-block ml-3 overflow-visible">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center p-2 rounded-full text-gray-400
|
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
|
hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2
|
||||||
focus:ring-blue-500"
|
focus:ring-blue-500"
|
||||||
data-settings-dropdown="${index}"
|
data-settings-dropdown="${index}"
|
||||||
>
|
>
|
||||||
@@ -234,12 +289,12 @@ async function loadUserVideos(pubkey) {
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
id="settingsDropdown-${index}"
|
id="settingsDropdown-${index}"
|
||||||
class="hidden absolute right-0 bottom-full mb-2 w-32
|
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"
|
rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||||
>
|
>
|
||||||
<div class="py-1">
|
<div class="py-1">
|
||||||
<button
|
<button
|
||||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100
|
class="block w-full text-left px-4 py-2 text-sm text-gray-100
|
||||||
hover:bg-gray-700"
|
hover:bg-gray-700"
|
||||||
data-edit-index="${index}"
|
data-edit-index="${index}"
|
||||||
>
|
>
|
||||||
@@ -247,7 +302,7 @@ async function loadUserVideos(pubkey) {
|
|||||||
</button>
|
</button>
|
||||||
${revertButton}
|
${revertButton}
|
||||||
<button
|
<button
|
||||||
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
||||||
hover:bg-red-700 hover:text-white"
|
hover:bg-red-700 hover:text-white"
|
||||||
data-delete-all-index="${index}"
|
data-delete-all-index="${index}"
|
||||||
>
|
>
|
||||||
@@ -262,8 +317,8 @@ async function loadUserVideos(pubkey) {
|
|||||||
// Fallback thumbnail
|
// Fallback thumbnail
|
||||||
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
|
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
|
||||||
const safeThumb = video.thumbnail || fallbackThumb;
|
const safeThumb = video.thumbnail || fallbackThumb;
|
||||||
|
const safeTitle = escapeHTML(video.title);
|
||||||
|
|
||||||
// Build the card
|
|
||||||
const cardEl = document.createElement("div");
|
const cardEl = document.createElement("div");
|
||||||
cardEl.classList.add(
|
cardEl.classList.add(
|
||||||
"bg-gray-900",
|
"bg-gray-900",
|
||||||
@@ -281,17 +336,17 @@ async function loadUserVideos(pubkey) {
|
|||||||
<img
|
<img
|
||||||
src="${fallbackThumb}"
|
src="${fallbackThumb}"
|
||||||
data-lazy="${escapeHTML(safeThumb)}"
|
data-lazy="${escapeHTML(safeThumb)}"
|
||||||
alt="${escapeHTML(video.title)}"
|
alt="${safeTitle}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 flex items-center justify-between">
|
<div class="p-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="text-lg font-bold text-white mb-2 line-clamp-2"
|
class="text-lg font-bold text-white mb-2 line-clamp-2"
|
||||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||||
>
|
>
|
||||||
${escapeHTML(video.title)}
|
${safeTitle}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
${new Date(video.created_at * 1000).toLocaleString()}
|
${new Date(video.created_at * 1000).toLocaleString()}
|
||||||
@@ -301,7 +356,7 @@ async function loadUserVideos(pubkey) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Clicking the card (except gear) => open video
|
// Clicking the card => open the video modal
|
||||||
cardEl.addEventListener("click", () => {
|
cardEl.addEventListener("click", () => {
|
||||||
app.playVideoByEventId(video.id);
|
app.playVideoByEventId(video.id);
|
||||||
});
|
});
|
||||||
@@ -311,7 +366,7 @@ async function loadUserVideos(pubkey) {
|
|||||||
|
|
||||||
container.appendChild(fragment);
|
container.appendChild(fragment);
|
||||||
|
|
||||||
// Lazy-load
|
// Lazy-load images
|
||||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||||
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
||||||
|
|
||||||
@@ -328,39 +383,36 @@ async function loadUserVideos(pubkey) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// "Edit" handler
|
// Edit handler
|
||||||
const editBtns = container.querySelectorAll("[data-edit-index]");
|
const editBtns = container.querySelectorAll("[data-edit-index]");
|
||||||
editBtns.forEach((btn) => {
|
editBtns.forEach((btn) => {
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
|
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
|
||||||
// Hide the dropdown
|
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
if (dropdown) dropdown.classList.add("hidden");
|
||||||
app.handleEditVideo(idx);
|
app.handleEditVideo(idx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// "Revert" handler
|
// Revert handler
|
||||||
const revertBtns = container.querySelectorAll("[data-revert-index]");
|
const revertBtns = container.querySelectorAll("[data-revert-index]");
|
||||||
revertBtns.forEach((btn) => {
|
revertBtns.forEach((btn) => {
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const idx = parseInt(btn.getAttribute("data-revert-index"), 10);
|
const idx = parseInt(btn.getAttribute("data-revert-index"), 10);
|
||||||
// Hide the dropdown
|
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
if (dropdown) dropdown.classList.add("hidden");
|
||||||
app.handleRevertVideo(idx);
|
app.handleRevertVideo(idx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// "Delete All" handler
|
// Delete All handler
|
||||||
const deleteAllBtns = container.querySelectorAll("[data-delete-all-index]");
|
const deleteAllBtns = container.querySelectorAll("[data-delete-all-index]");
|
||||||
deleteAllBtns.forEach((btn) => {
|
deleteAllBtns.forEach((btn) => {
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10);
|
const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10);
|
||||||
// Hide the dropdown
|
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
if (dropdown) dropdown.classList.add("hidden");
|
||||||
app.handleFullDeleteVideo(idx);
|
app.handleFullDeleteVideo(idx);
|
||||||
@@ -372,14 +424,14 @@ async function loadUserVideos(pubkey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal placeholder decryption for private videos.
|
* Minimal placeholder for private video decryption.
|
||||||
*/
|
*/
|
||||||
function fakeDecrypt(str) {
|
function fakeDecrypt(str) {
|
||||||
return str.split("").reverse().join("");
|
return str.split("").reverse().join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicate older overshadowed versions – return only the newest for each root.
|
* Keep only the newest version of each video root.
|
||||||
*/
|
*/
|
||||||
function dedupeToNewestByRoot(videos) {
|
function dedupeToNewestByRoot(videos) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -394,7 +446,7 @@ function dedupeToNewestByRoot(videos) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a raw Nostr event => "video" object.
|
* Convert raw event => "video" object.
|
||||||
*/
|
*/
|
||||||
function localConvertEventToVideo(event) {
|
function localConvertEventToVideo(event) {
|
||||||
try {
|
try {
|
||||||
@@ -431,7 +483,7 @@ function localConvertEventToVideo(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape HTML to prevent injection or XSS.
|
* Basic escaping to avoid XSS.
|
||||||
*/
|
*/
|
||||||
function escapeHTML(unsafe = "") {
|
function escapeHTML(unsafe = "") {
|
||||||
return unsafe
|
return unsafe
|
||||||
|
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,5 +1,6 @@
|
|||||||
// js/viewManager.js
|
// js/viewManager.js
|
||||||
import { initChannelProfileView } from "./channelProfile.js";
|
import { initChannelProfileView } from "./channelProfile.js";
|
||||||
|
import { subscriptions } from "./subscriptions.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a partial view by URL into the #viewContainer.
|
* Load a partial view by URL into the #viewContainer.
|
||||||
@@ -53,11 +54,33 @@ export const viewInitRegistry = {
|
|||||||
explore: () => {
|
explore: () => {
|
||||||
console.log("Explore view loaded.");
|
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.");
|
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": () => {
|
"channel-profile": () => {
|
||||||
// Call the initialization function from channelProfile.js
|
// Call the initialization function from channelProfile.js
|
||||||
initChannelProfileView();
|
initChannelProfileView();
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
@@ -1,52 +1,58 @@
|
|||||||
<section class="channel-profile-container">
|
<section class="channel-profile-container">
|
||||||
<!-- Modified banner wrapper for taller, rounded style -->
|
<!-- Modified banner wrapper for taller, rounded style -->
|
||||||
<div class="profile-banner relative mb-6 rounded-lg overflow-hidden shadow-lg">
|
<div
|
||||||
<!-- Increase the default height classes -->
|
class="profile-banner relative mb-6 rounded-lg overflow-hidden shadow-lg"
|
||||||
<img
|
>
|
||||||
id="channelBanner"
|
<!-- Increase the default height classes -->
|
||||||
class="w-full h-48 md:h-64 object-cover"
|
<img
|
||||||
src=""
|
id="channelBanner"
|
||||||
alt="Banner"
|
class="w-full h-48 md:h-64 object-cover"
|
||||||
/>
|
src=""
|
||||||
<div class="absolute inset-0 bg-black bg-opacity-50"></div>
|
alt="Banner"
|
||||||
|
/>
|
||||||
<div class="absolute bottom-4 left-4 flex items-center">
|
<div class="absolute inset-0 bg-black bg-opacity-50"></div>
|
||||||
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
|
|
||||||
<img
|
<div class="absolute bottom-4 left-4 flex items-center">
|
||||||
id="channelAvatar"
|
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
|
||||||
src="assets/svg/default-profile.svg"
|
<img
|
||||||
alt="Avatar"
|
id="channelAvatar"
|
||||||
class="w-full h-full object-cover"
|
src="assets/svg/default-profile.svg"
|
||||||
/>
|
alt="Avatar"
|
||||||
</div>
|
class="w-full h-full object-cover"
|
||||||
<div class="ml-4 text-white">
|
/>
|
||||||
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
|
</div>
|
||||||
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
|
<div class="ml-4 text-white">
|
||||||
</div>
|
<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>
|
||||||
|
</div>
|
||||||
<div class="profile-details px-4 mt-4">
|
|
||||||
<p id="channelAbout" class="mb-2 text-gray-700"></p>
|
<!-- Basic profile details: about text, website, LN address -->
|
||||||
<a
|
<div class="profile-details px-4 mt-4">
|
||||||
id="channelWebsite"
|
<p id="channelAbout" class="mb-2 text-gray-700"></p>
|
||||||
href="#"
|
<a
|
||||||
target="_blank"
|
id="channelWebsite"
|
||||||
class="block text-blue-500 hover:underline mb-2"
|
href="#"
|
||||||
>Website</a
|
target="_blank"
|
||||||
>
|
class="block text-blue-500 hover:underline mb-2"
|
||||||
<p id="channelLightning" class="text-sm text-gray-500"></p>
|
>Website</a
|
||||||
</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 -->
|
<p id="channelLightning" class="text-sm text-gray-500"></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<!-- 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 -->
|
<!-- views/subscriptions.html -->
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-xl mb-4 text-gray-700">Subscriptions</h2>
|
<h2 class="text-xl mb-4">Subscriptions</h2>
|
||||||
<p class="text-gray-600">Coming Soon...</p>
|
<div
|
||||||
|
id="subscriptionsVideoList"
|
||||||
|
style="
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
"
|
||||||
|
></div>
|
||||||
</section>
|
</section>
|
||||||
|
Reference in New Issue
Block a user