mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add profile modal scaffolding and video overflow menus
This commit is contained in:
1 parent
8966b426f8
commit
5365fad888
6 files changed
+897
-38
No files matched your search
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="5" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="19" cy="12" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 267 B |
+129
-23
@@ -2,6 +2,10 @@
|
||||
<div
|
||||
id="profileModal"
|
||||
class="fixed inset-0 z-50 hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="profileModalTitle"
|
||||
tabindex="-1"
|
||||
style="background: transparent"
|
||||
>
|
||||
<!-- Overlay Layer with dark background and blur -->
|
||||
@@ -27,7 +31,7 @@
|
||||
<div
|
||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white">Profile</h2>
|
||||
<h2 id="profileModalTitle" class="text-xl font-bold text-white">Profile</h2>
|
||||
<button
|
||||
id="closeProfileModal"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
|
||||
@@ -50,35 +54,137 @@
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6 flex-1 text-gray-200 space-y-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<img
|
||||
id="profileModalAvatar"
|
||||
src="assets/svg/default-profile.svg"
|
||||
alt="Profile"
|
||||
class="w-16 h-16 object-cover rounded-full"
|
||||
/>
|
||||
<p id="profileModalName" class="text-lg font-semibold truncate">
|
||||
Loading...
|
||||
</p>
|
||||
<div class="p-6 flex-1 overflow-hidden">
|
||||
<div class="flex flex-col lg:flex-row gap-6 h-full">
|
||||
<!-- Left navigation column -->
|
||||
<nav
|
||||
class="flex lg:flex-col gap-2 flex-shrink-0"
|
||||
aria-label="Profile sections"
|
||||
>
|
||||
<button
|
||||
id="profileNavAccount"
|
||||
type="button"
|
||||
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-white bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
aria-controls="profilePaneAccount"
|
||||
aria-selected="true"
|
||||
>
|
||||
Account
|
||||
</button>
|
||||
<button
|
||||
id="profileNavRelays"
|
||||
type="button"
|
||||
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
aria-controls="profilePaneRelays"
|
||||
aria-selected="false"
|
||||
>
|
||||
Relays
|
||||
</button>
|
||||
<button
|
||||
id="profileNavBlocked"
|
||||
type="button"
|
||||
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
aria-controls="profilePaneBlocked"
|
||||
aria-selected="false"
|
||||
>
|
||||
Blocked
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Right content panes -->
|
||||
<div class="flex-1 min-h-[320px] overflow-y-auto pr-1">
|
||||
<section
|
||||
id="profilePaneAccount"
|
||||
class="profile-pane space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavAccount"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<img
|
||||
id="profileModalAvatar"
|
||||
src="assets/svg/default-profile.svg"
|
||||
alt="Profile"
|
||||
class="w-16 h-16 object-cover rounded-full"
|
||||
/>
|
||||
<p id="profileModalName" class="text-lg font-semibold truncate">
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
id="profileChannelLink"
|
||||
href="#"
|
||||
class="inline-flex items-center text-sm text-blue-400 hover:text-blue-300 transition-colors hidden"
|
||||
>
|
||||
View your channel
|
||||
</a>
|
||||
<p class="text-sm text-gray-400">
|
||||
Manage your account details and upcoming preferences here.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneRelays"
|
||||
class="profile-pane hidden space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavRelays"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-400">
|
||||
Configure the relays Bitvid will use to discover and publish your videos.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-gray-300" for="relayInput">
|
||||
<span class="mb-2 block font-medium">Add a relay</span>
|
||||
<input
|
||||
id="relayInput"
|
||||
type="url"
|
||||
placeholder="wss://relay.example.com"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
id="addRelayBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Add relay
|
||||
</button>
|
||||
<button
|
||||
id="restoreRelaysBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-gray-800 text-sm font-medium text-gray-200 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Restore defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="relayList" class="space-y-3"></ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneBlocked"
|
||||
class="profile-pane hidden space-y-4"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavBlocked"
|
||||
>
|
||||
<div
|
||||
id="blockedEmpty"
|
||||
class="rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
|
||||
>
|
||||
You haven’t blocked any creators yet.
|
||||
</div>
|
||||
<ul id="blockedList" class="space-y-3"></ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
id="profileChannelLink"
|
||||
href="#"
|
||||
class="inline-flex items-center text-sm text-blue-400 hover:text-blue-300 transition-colors hidden"
|
||||
>
|
||||
View your channel
|
||||
</a>
|
||||
<p class="text-sm text-gray-400">
|
||||
Some future profile details or settings could go here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="p-4 border-t border-gray-700 flex items-center justify-end">
|
||||
<button
|
||||
id="profileLogoutBtn"
|
||||
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
|
||||
@@ -31,6 +31,55 @@
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="relative ml-auto"
|
||||
data-more-menu-wrapper="true"
|
||||
>
|
||||
<button
|
||||
id="modalMoreBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="More"
|
||||
data-more-dropdown="modal"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5" />
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-modal"
|
||||
class="hidden absolute right-0 top-14 w-44 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
>
|
||||
<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-action="modal-open-channel"
|
||||
>
|
||||
Open channel
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="modal-copy-link"
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-action="modal-block-author"
|
||||
>
|
||||
Block creator
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="modal-report"
|
||||
>
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -499,6 +499,26 @@ class bitvidApp {
|
||||
this.profileModalAvatar = null;
|
||||
this.profileModalName = null;
|
||||
this.profileChannelLink = null;
|
||||
this.profileNavButtons = {
|
||||
account: null,
|
||||
relays: null,
|
||||
blocked: null,
|
||||
};
|
||||
this.profilePaneElements = {
|
||||
account: null,
|
||||
relays: null,
|
||||
blocked: null,
|
||||
};
|
||||
this.profileRelayList = null;
|
||||
this.profileBlockedList = null;
|
||||
this.profileBlockedEmpty = null;
|
||||
this.profileRelayInput = null;
|
||||
this.profileAddRelayBtn = null;
|
||||
this.profileRestoreRelaysBtn = null;
|
||||
this.lastFocusedBeforeProfileModal = null;
|
||||
this.boundProfileModalKeydown = null;
|
||||
this.boundProfileModalFocusIn = null;
|
||||
this.profileModalFocusables = [];
|
||||
|
||||
// Upload modal elements
|
||||
this.uploadButton = document.getElementById("uploadButton") || null;
|
||||
@@ -597,6 +617,8 @@ class bitvidApp {
|
||||
this.copyMagnetBtn = null;
|
||||
this.shareBtn = null;
|
||||
this.modalZapBtn = null;
|
||||
this.modalMoreBtn = null;
|
||||
this.modalMoreMenu = null;
|
||||
this.modalPosterCleanup = null;
|
||||
this.cleanupPromise = null;
|
||||
|
||||
@@ -618,6 +640,9 @@ class bitvidApp {
|
||||
|
||||
// Videos stored as a Map (key=event.id)
|
||||
this.videosMap = new Map();
|
||||
this.moreMenuGlobalHandlerBound = false;
|
||||
this.boundMoreMenuDocumentClick = null;
|
||||
this.boundMoreMenuDocumentKeydown = null;
|
||||
// Simple cache for user profiles
|
||||
this.profileCache = new Map();
|
||||
this.lastRenderedVideoSignature = null;
|
||||
@@ -993,6 +1018,8 @@ class bitvidApp {
|
||||
this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null;
|
||||
this.shareBtn = document.getElementById("shareBtn") || null;
|
||||
this.modalZapBtn = document.getElementById("modalZapBtn") || null;
|
||||
this.modalMoreBtn = document.getElementById("modalMoreBtn") || null;
|
||||
this.modalMoreMenu = document.getElementById("moreDropdown-modal") || null;
|
||||
this.setModalZapVisibility(false);
|
||||
|
||||
// Attach existing event listeners for copy/share
|
||||
@@ -1037,6 +1064,10 @@ class bitvidApp {
|
||||
this.openCreatorChannel();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.playerModal) {
|
||||
this.attachMoreMenuHandlers(this.playerModal);
|
||||
}
|
||||
}
|
||||
|
||||
goToProfile(pubkey) {
|
||||
@@ -3223,35 +3254,85 @@ class bitvidApp {
|
||||
document.getElementById("profileModalName") || null;
|
||||
this.profileChannelLink =
|
||||
document.getElementById("profileChannelLink") || null;
|
||||
this.profileNavButtons.account =
|
||||
document.getElementById("profileNavAccount") || null;
|
||||
this.profileNavButtons.relays =
|
||||
document.getElementById("profileNavRelays") || null;
|
||||
this.profileNavButtons.blocked =
|
||||
document.getElementById("profileNavBlocked") || null;
|
||||
this.profilePaneElements.account =
|
||||
document.getElementById("profilePaneAccount") || null;
|
||||
this.profilePaneElements.relays =
|
||||
document.getElementById("profilePaneRelays") || null;
|
||||
this.profilePaneElements.blocked =
|
||||
document.getElementById("profilePaneBlocked") || null;
|
||||
this.profileRelayList = document.getElementById("relayList") || null;
|
||||
this.profileBlockedList = document.getElementById("blockedList") || null;
|
||||
this.profileBlockedEmpty =
|
||||
document.getElementById("blockedEmpty") || null;
|
||||
this.profileRelayInput = document.getElementById("relayInput") || null;
|
||||
this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null;
|
||||
this.profileRestoreRelaysBtn =
|
||||
document.getElementById("restoreRelaysBtn") || null;
|
||||
|
||||
// Wire up
|
||||
if (this.closeProfileModal) {
|
||||
if (this.closeProfileModal && !this.closeProfileModal.dataset.bound) {
|
||||
this.closeProfileModal.dataset.bound = "true";
|
||||
this.closeProfileModal.addEventListener("click", () => {
|
||||
this.profileModal.classList.add("hidden");
|
||||
this.hideProfileModal();
|
||||
});
|
||||
}
|
||||
if (this.profileLogoutBtn) {
|
||||
if (this.profileLogoutBtn && !this.profileLogoutBtn.dataset.bound) {
|
||||
this.profileLogoutBtn.dataset.bound = "true";
|
||||
this.profileLogoutBtn.addEventListener("click", () => {
|
||||
// On "Logout" inside the profile modal
|
||||
this.logout();
|
||||
this.profileModal.classList.add("hidden");
|
||||
this.hideProfileModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.profileChannelLink) {
|
||||
if (this.profileChannelLink && !this.profileChannelLink.dataset.bound) {
|
||||
this.profileChannelLink.dataset.bound = "true";
|
||||
this.profileChannelLink.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
const targetNpub = this.profileChannelLink?.dataset?.targetNpub;
|
||||
if (!targetNpub) {
|
||||
return;
|
||||
}
|
||||
if (this.profileModal) {
|
||||
this.profileModal.classList.add("hidden");
|
||||
}
|
||||
this.hideProfileModal();
|
||||
window.location.hash = `#view=channel-profile&npub=${targetNpub}`;
|
||||
});
|
||||
}
|
||||
|
||||
Object.entries(this.profileNavButtons).forEach(([name, button]) => {
|
||||
if (!button || button.dataset.navBound === "true") {
|
||||
return;
|
||||
}
|
||||
button.dataset.navBound = "true";
|
||||
button.addEventListener("click", () => {
|
||||
this.selectProfilePane(name);
|
||||
});
|
||||
});
|
||||
|
||||
if (this.profileAddRelayBtn && !this.profileAddRelayBtn.dataset.bound) {
|
||||
this.profileAddRelayBtn.dataset.bound = "true";
|
||||
this.profileAddRelayBtn.addEventListener("click", () => {
|
||||
this.showSuccess("Relay management coming soon.");
|
||||
});
|
||||
}
|
||||
if (
|
||||
this.profileRestoreRelaysBtn &&
|
||||
this.profileRestoreRelaysBtn.dataset.bound !== "true"
|
||||
) {
|
||||
this.profileRestoreRelaysBtn.dataset.bound = "true";
|
||||
this.profileRestoreRelaysBtn.addEventListener("click", () => {
|
||||
this.showSuccess("Relay management coming soon.");
|
||||
});
|
||||
}
|
||||
|
||||
this.selectProfilePane("account");
|
||||
this.populateProfileRelays();
|
||||
this.populateBlockedList();
|
||||
|
||||
console.log("Profile modal initialization successful");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -3261,6 +3342,332 @@ class bitvidApp {
|
||||
}
|
||||
}
|
||||
|
||||
selectProfilePane(name = "account") {
|
||||
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
|
||||
const target = ["account", "relays", "blocked"].includes(normalized)
|
||||
? normalized
|
||||
: "account";
|
||||
|
||||
Object.entries(this.profilePaneElements).forEach(([key, pane]) => {
|
||||
if (!pane) {
|
||||
return;
|
||||
}
|
||||
const isActive = key === target;
|
||||
pane.classList.toggle("hidden", !isActive);
|
||||
pane.setAttribute("aria-hidden", (!isActive).toString());
|
||||
});
|
||||
|
||||
Object.entries(this.profileNavButtons).forEach(([key, button]) => {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const isActive = key === target;
|
||||
button.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
button.classList.toggle("bg-gray-800", isActive);
|
||||
button.classList.toggle("text-white", isActive);
|
||||
button.classList.toggle("text-gray-400", !isActive);
|
||||
});
|
||||
|
||||
this.updateProfileModalFocusables();
|
||||
}
|
||||
|
||||
updateProfileModalFocusables() {
|
||||
if (!this.profileModal) {
|
||||
this.profileModalFocusables = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableSelectors =
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
|
||||
const candidates = Array.from(
|
||||
this.profileModal.querySelectorAll(focusableSelectors)
|
||||
);
|
||||
|
||||
this.profileModalFocusables = candidates.filter((el) => {
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
if (el.hasAttribute("disabled")) {
|
||||
return false;
|
||||
}
|
||||
if (el.getAttribute("aria-hidden") === "true") {
|
||||
return false;
|
||||
}
|
||||
if (el.offsetParent === null && el !== document.activeElement) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
openProfileModal() {
|
||||
if (!this.profileModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectProfilePane("account");
|
||||
this.populateProfileRelays();
|
||||
this.populateBlockedList();
|
||||
|
||||
this.profileModal.classList.remove("hidden");
|
||||
this.profileModal.setAttribute("aria-hidden", "false");
|
||||
|
||||
this.lastFocusedBeforeProfileModal =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
|
||||
this.updateProfileModalFocusables();
|
||||
|
||||
const initialTarget =
|
||||
this.profileNavButtons.account instanceof HTMLElement
|
||||
? this.profileNavButtons.account
|
||||
: this.profileModal;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (initialTarget && typeof initialTarget.focus === "function") {
|
||||
initialTarget.focus();
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.boundProfileModalKeydown) {
|
||||
this.boundProfileModalKeydown = (event) => {
|
||||
if (!this.profileModal || this.profileModal.classList.contains("hidden")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.hideProfileModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateProfileModalFocusables();
|
||||
if (!this.profileModalFocusables.length) {
|
||||
event.preventDefault();
|
||||
if (typeof this.profileModal.focus === "function") {
|
||||
this.profileModal.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const first = this.profileModalFocusables[0];
|
||||
const last = this.profileModalFocusables[this.profileModalFocusables.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (active === first || !this.profileModal.contains(active)) {
|
||||
event.preventDefault();
|
||||
if (last && typeof last.focus === "function") {
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (active === last) {
|
||||
event.preventDefault();
|
||||
if (first && typeof first.focus === "function") {
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.boundProfileModalFocusIn) {
|
||||
this.boundProfileModalFocusIn = (event) => {
|
||||
if (
|
||||
!this.profileModal ||
|
||||
this.profileModal.classList.contains("hidden") ||
|
||||
this.profileModal.contains(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateProfileModalFocusables();
|
||||
const fallback = this.profileModalFocusables[0] || this.profileModal;
|
||||
if (fallback && typeof fallback.focus === "function") {
|
||||
fallback.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this.profileModal.addEventListener("keydown", this.boundProfileModalKeydown);
|
||||
document.addEventListener("focusin", this.boundProfileModalFocusIn);
|
||||
}
|
||||
|
||||
hideProfileModal() {
|
||||
if (!this.profileModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.profileModal.classList.contains("hidden")) {
|
||||
this.profileModal.classList.add("hidden");
|
||||
}
|
||||
this.profileModal.setAttribute("aria-hidden", "true");
|
||||
|
||||
if (this.boundProfileModalKeydown) {
|
||||
this.profileModal.removeEventListener(
|
||||
"keydown",
|
||||
this.boundProfileModalKeydown
|
||||
);
|
||||
}
|
||||
if (this.boundProfileModalFocusIn) {
|
||||
document.removeEventListener("focusin", this.boundProfileModalFocusIn);
|
||||
}
|
||||
|
||||
this.closeAllMoreMenus();
|
||||
|
||||
if (
|
||||
this.lastFocusedBeforeProfileModal &&
|
||||
typeof this.lastFocusedBeforeProfileModal.focus === "function"
|
||||
) {
|
||||
this.lastFocusedBeforeProfileModal.focus();
|
||||
}
|
||||
this.lastFocusedBeforeProfileModal = null;
|
||||
}
|
||||
|
||||
populateProfileRelays(relayUrls) {
|
||||
if (!this.profileRelayList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawRelays =
|
||||
Array.isArray(relayUrls)
|
||||
? relayUrls
|
||||
: Array.isArray(nostrClient.relays)
|
||||
? nostrClient.relays
|
||||
: Array.from(nostrClient.relays || []);
|
||||
|
||||
const relays = rawRelays
|
||||
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
|
||||
.filter((relay) => relay.length > 0);
|
||||
|
||||
this.profileRelayList.innerHTML = "";
|
||||
|
||||
if (!relays.length) {
|
||||
const emptyState = document.createElement("li");
|
||||
emptyState.className =
|
||||
"rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400";
|
||||
emptyState.textContent = "No relays configured.";
|
||||
this.profileRelayList.appendChild(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
relays.forEach((relayUrl) => {
|
||||
const item = document.createElement("li");
|
||||
item.className =
|
||||
"flex items-start justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3";
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "flex-1 min-w-0";
|
||||
|
||||
const urlEl = document.createElement("p");
|
||||
urlEl.className = "text-sm font-medium text-gray-100 break-all";
|
||||
urlEl.textContent = relayUrl;
|
||||
|
||||
const statusEl = document.createElement("p");
|
||||
statusEl.className = "mt-1 text-xs text-gray-400";
|
||||
statusEl.textContent = "Active";
|
||||
|
||||
info.appendChild(urlEl);
|
||||
info.appendChild(statusEl);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "flex items-center gap-2";
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.type = "button";
|
||||
editBtn.className =
|
||||
"px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.addEventListener("click", () => {
|
||||
this.showSuccess("Relay management coming soon.");
|
||||
});
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className =
|
||||
"px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
|
||||
removeBtn.textContent = "Remove";
|
||||
removeBtn.addEventListener("click", () => {
|
||||
this.showSuccess("Relay management coming soon.");
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(removeBtn);
|
||||
|
||||
item.appendChild(info);
|
||||
item.appendChild(actions);
|
||||
|
||||
this.profileRelayList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
populateBlockedList(blocked = []) {
|
||||
if (!this.profileBlockedList || !this.profileBlockedEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Array.isArray(blocked) ? blocked : [];
|
||||
this.profileBlockedList.innerHTML = "";
|
||||
|
||||
if (!entries.length) {
|
||||
this.profileBlockedEmpty.classList.remove("hidden");
|
||||
this.profileBlockedList.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
this.profileBlockedEmpty.classList.add("hidden");
|
||||
this.profileBlockedList.classList.remove("hidden");
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const identifier =
|
||||
typeof entry === "string"
|
||||
? entry
|
||||
: typeof entry?.npub === "string" && entry.npub
|
||||
? entry.npub
|
||||
: typeof entry?.pubkey === "string"
|
||||
? entry.pubkey
|
||||
: "";
|
||||
|
||||
if (!identifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = document.createElement("li");
|
||||
item.className =
|
||||
"flex items-center justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "min-w-0";
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.className = "text-sm font-medium text-gray-100 break-all";
|
||||
title.textContent = identifier;
|
||||
|
||||
label.appendChild(title);
|
||||
|
||||
const actionBtn = document.createElement("button");
|
||||
actionBtn.type = "button";
|
||||
actionBtn.className =
|
||||
"px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
|
||||
actionBtn.textContent = "Remove";
|
||||
actionBtn.addEventListener("click", () => {
|
||||
this.showSuccess("Block management coming soon.");
|
||||
});
|
||||
|
||||
item.appendChild(label);
|
||||
item.appendChild(actionBtn);
|
||||
|
||||
this.profileBlockedList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup general event listeners for logout, modals, etc.
|
||||
*/
|
||||
@@ -3275,9 +3682,7 @@ class bitvidApp {
|
||||
// 2) Profile button
|
||||
if (this.profileButton) {
|
||||
this.profileButton.addEventListener("click", () => {
|
||||
if (this.profileModal) {
|
||||
this.profileModal.classList.remove("hidden");
|
||||
}
|
||||
this.openProfileModal();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4934,6 +5339,48 @@ class bitvidApp {
|
||||
`
|
||||
: "";
|
||||
|
||||
const moreMenu = `
|
||||
<div class="relative inline-block ml-1 overflow-visible" data-more-menu-wrapper="true">
|
||||
<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-more-dropdown="${index}"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="More options"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5 object-contain" />
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-${index}"
|
||||
class="hidden absolute right-0 bottom-full mb-2 w-40 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
>
|
||||
<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-action="open-channel" data-author="${video.pubkey || ""}">
|
||||
Open channel
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="copy-link" data-event-id="${video.id || ""}">
|
||||
Copy link
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" data-action="block-author" data-author="${video.pubkey || ""}">
|
||||
Block creator
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="report" data-event-id="${video.id || ""}">
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const cardControls = `
|
||||
<div class="flex items-center">
|
||||
${moreMenu}${gearMenu}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
|
||||
const trimmedMagnet =
|
||||
typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||||
@@ -5057,7 +5504,7 @@ class bitvidApp {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${gearMenu}
|
||||
${cardControls}
|
||||
</div>
|
||||
${connectionBadgesHtml}
|
||||
${torrentWarningHtml}
|
||||
@@ -5265,6 +5712,7 @@ class bitvidApp {
|
||||
// with HTTP 200 + empty body). We set up the listeners before kicking off
|
||||
// any lazy-loading observers so cached failures are covered as well.
|
||||
this.bindThumbnailFallbacks(this.videoList);
|
||||
this.attachMoreMenuHandlers(this.videoList);
|
||||
|
||||
// Lazy-load images
|
||||
const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
|
||||
@@ -5445,6 +5893,169 @@ class bitvidApp {
|
||||
});
|
||||
}
|
||||
|
||||
ensureGlobalMoreMenuHandlers() {
|
||||
if (this.moreMenuGlobalHandlerBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.moreMenuGlobalHandlerBound = true;
|
||||
|
||||
this.boundMoreMenuDocumentClick = (event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
(target.closest("[data-more-menu-wrapper]") ||
|
||||
target.closest("[data-more-menu]"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.closeAllMoreMenus();
|
||||
};
|
||||
|
||||
this.boundMoreMenuDocumentKeydown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
this.closeAllMoreMenus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", this.boundMoreMenuDocumentClick);
|
||||
document.addEventListener("keydown", this.boundMoreMenuDocumentKeydown);
|
||||
}
|
||||
|
||||
closeAllMoreMenus() {
|
||||
const menus = document.querySelectorAll("[data-more-menu]");
|
||||
menus.forEach((menu) => {
|
||||
if (menu instanceof HTMLElement) {
|
||||
menu.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
const buttons = document.querySelectorAll("[data-more-dropdown]");
|
||||
buttons.forEach((btn) => {
|
||||
if (btn instanceof HTMLElement) {
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
attachMoreMenuHandlers(container) {
|
||||
if (!container || typeof container.querySelectorAll !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttons = container.querySelectorAll("[data-more-dropdown]");
|
||||
if (!buttons.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureGlobalMoreMenuHandlers();
|
||||
|
||||
buttons.forEach((button) => {
|
||||
if (!(button instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (button.dataset.moreMenuToggleBound === "true") {
|
||||
return;
|
||||
}
|
||||
button.dataset.moreMenuToggleBound = "true";
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const key = button.getAttribute("data-more-dropdown") || "";
|
||||
const dropdown = document.getElementById(`moreDropdown-${key}`);
|
||||
if (!(dropdown instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const willOpen = dropdown.classList.contains("hidden");
|
||||
this.closeAllMoreMenus();
|
||||
|
||||
if (willOpen) {
|
||||
dropdown.classList.remove("hidden");
|
||||
button.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const actionButtons = container.querySelectorAll(
|
||||
"[data-more-menu] button[data-action]"
|
||||
);
|
||||
actionButtons.forEach((button) => {
|
||||
if (!(button instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (button.dataset.moreMenuActionBound === "true") {
|
||||
return;
|
||||
}
|
||||
button.dataset.moreMenuActionBound = "true";
|
||||
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const { action } = button.dataset;
|
||||
this.handleMoreMenuAction(action, button.dataset);
|
||||
this.closeAllMoreMenus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleMoreMenuAction(action, dataset = {}) {
|
||||
const normalized = typeof action === "string" ? action.trim() : "";
|
||||
|
||||
switch (normalized) {
|
||||
case "open-channel": {
|
||||
const author = dataset.author || "";
|
||||
if (author) {
|
||||
this.goToProfile(author);
|
||||
} else {
|
||||
this.showError("No creator info available.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "modal-open-channel": {
|
||||
this.openCreatorChannel();
|
||||
break;
|
||||
}
|
||||
case "copy-link":
|
||||
case "modal-copy-link": {
|
||||
const eventId =
|
||||
dataset.eventId ||
|
||||
(normalized === "modal-copy-link" && this.currentVideo
|
||||
? this.currentVideo.id
|
||||
: "");
|
||||
if (!eventId) {
|
||||
this.showError("Could not generate link.");
|
||||
break;
|
||||
}
|
||||
const shareUrl = this.buildShareUrlFromEventId(eventId);
|
||||
if (!shareUrl) {
|
||||
this.showError("Could not generate link.");
|
||||
break;
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(shareUrl)
|
||||
.then(() => this.showSuccess("Video link copied to clipboard!"))
|
||||
.catch(() => this.showError("Failed to copy the link."));
|
||||
break;
|
||||
}
|
||||
case "block-author":
|
||||
case "modal-block-author": {
|
||||
this.showSuccess("Block management coming soon.");
|
||||
break;
|
||||
}
|
||||
case "report":
|
||||
case "modal-report": {
|
||||
this.showSuccess("Reporting coming soon.");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the modal to reflect current torrent stats.
|
||||
* We remove the unused torrent.status references,
|
||||
|
||||
+45
-1
@@ -387,6 +387,48 @@ async function loadUserVideos(pubkey) {
|
||||
`;
|
||||
}
|
||||
|
||||
const moreMenu = `
|
||||
<div class="relative inline-block ml-1 overflow-visible" data-more-menu-wrapper="true">
|
||||
<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-more-dropdown="${index}"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="More options"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5 object-contain" />
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-${index}"
|
||||
class="hidden absolute right-0 bottom-full mb-2 w-40 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
>
|
||||
<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-action="open-channel" data-author="${video.pubkey || ""}">
|
||||
Open channel
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="copy-link" data-event-id="${video.id || ""}">
|
||||
Copy link
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" data-action="block-author" data-author="${video.pubkey || ""}">
|
||||
Block creator
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="report" data-event-id="${video.id || ""}">
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const cardControls = `
|
||||
<div class="flex items-center">
|
||||
${moreMenu}${gearMenu}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Fallback thumbnail
|
||||
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
|
||||
const safeThumb = video.thumbnail || fallbackThumb;
|
||||
@@ -470,7 +512,7 @@ async function loadUserVideos(pubkey) {
|
||||
${new Date(video.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
${gearMenu}
|
||||
${cardControls}
|
||||
</div>
|
||||
${connectionBadgesHtml}
|
||||
</div>
|
||||
@@ -576,6 +618,8 @@ async function loadUserVideos(pubkey) {
|
||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
||||
|
||||
app.attachMoreMenuHandlers(container);
|
||||
|
||||
// Gear menu toggles
|
||||
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
|
||||
gearButtons.forEach((btn) => {
|
||||
|
||||
+45
-1
@@ -377,6 +377,48 @@ class SubscriptionsManager {
|
||||
`
|
||||
: "";
|
||||
|
||||
const moreMenu = `
|
||||
<div class="relative inline-block ml-1 overflow-visible" data-more-menu-wrapper="true">
|
||||
<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-more-dropdown="${index}"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="More options"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5 object-contain" />
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-${index}"
|
||||
class="hidden absolute right-0 bottom-full mb-2 w-40 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
>
|
||||
<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-action="open-channel" data-author="${video.pubkey || ""}">
|
||||
Open channel
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="copy-link" data-event-id="${video.id || ""}">
|
||||
Copy link
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" data-action="block-author" data-author="${video.pubkey || ""}">
|
||||
Block creator
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="report" data-event-id="${video.id || ""}">
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const cardControls = `
|
||||
<div class="flex items-center">
|
||||
${moreMenu}${gearMenu}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
|
||||
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
|
||||
const playbackUrl =
|
||||
@@ -457,7 +499,7 @@ class SubscriptionsManager {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${gearMenu}
|
||||
${cardControls}
|
||||
</div>
|
||||
${connectionBadgesHtml}
|
||||
</div>
|
||||
@@ -566,6 +608,8 @@ class SubscriptionsManager {
|
||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||
lazyEls.forEach((el) => window.app?.mediaLoader.observe(el));
|
||||
|
||||
window.app?.attachMoreMenuHandlers?.(container);
|
||||
|
||||
// Gear menus
|
||||
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
|
||||
gearButtons.forEach((btn) => {
|
||||
|
||||
Reference in new issue
Block a user