From f9d81ecb24580d3a3d65ae1d5603a0eb2dcad83d Mon Sep 17 00:00:00 2001 From: Keep Creating Online Date: Sun, 9 Feb 2025 14:51:20 -0500 Subject: [PATCH 1/9] added channel/profile view --- components/disclaimer.html | 23 +-- css/style.css | 2 +- js/app.js | 156 ++++++++------ js/channelProfile.js | 403 +++++++++++++++++++++++++++++++++++++ js/index.js | 12 +- js/viewManager.js | 20 +- views/channel-profile.html | 52 +++++ 7 files changed, 573 insertions(+), 95 deletions(-) create mode 100644 js/channelProfile.js create mode 100644 views/channel-profile.html diff --git a/components/disclaimer.html b/components/disclaimer.html index a190d9c..72bb5b1 100644 --- a/components/disclaimer.html +++ b/components/disclaimer.html @@ -25,28 +25,7 @@
- + >
diff --git a/css/style.css b/css/style.css index e8bec95..1d3d72c 100644 --- a/css/style.css +++ b/css/style.css @@ -464,7 +464,6 @@ footer a:hover { padding-top: 56.25%; background-color: #1e293b; } - .ratio-16-9 > img { position: absolute; top: 0; @@ -526,3 +525,4 @@ footer a:hover { #sidebar hr { border-color: rgba(255, 255, 255, 0.1); } + diff --git a/js/app.js b/js/app.js index f6610ad..6ce8d4b 100644 --- a/js/app.js +++ b/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) { @@ -349,6 +349,56 @@ class bitvidApp { this.showError("Could not generate link."); } }); + } + + // Add click handlers for avatar and name => channel profile + if (this.creatorAvatar) { + this.creatorAvatar.style.cursor = "pointer"; + this.creatorAvatar.addEventListener("click", () => { + this.openCreatorChannel(); + }); + } + if (this.creatorName) { + this.creatorName.style.cursor = "pointer"; + this.creatorName.addEventListener("click", () => { + this.openCreatorChannel(); + }); + } + } + + goToProfile(pubkey) { + if (!pubkey) { + this.showError("No creator info available."); + return; + } + try { + const npub = window.NostrTools.nip19.npubEncode(pubkey); + // Switch to channel profile view + window.location.hash = `#view=channel-profile&npub=${npub}`; + } catch (err) { + console.error("Failed to go to channel:", err); + this.showError("Could not open channel."); + } + } + + openCreatorChannel() { + if (!this.currentVideo || !this.currentVideo.pubkey) { + this.showError("No creator info available."); + return; + } + + try { + // Encode the hex pubkey to npub + const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey); + + // Close the video modal + this.hideModal(); + + // Switch to channel profile view + window.location.hash = `#view=channel-profile&npub=${npub}`; + } catch (err) { + console.error("Failed to open creator channel:", err); + this.showError("Could not open channel."); } } @@ -1045,7 +1095,7 @@ class bitvidApp { async renderVideoList(videos) { if (!this.videoList) return; - // Check if there's anything to show + // 1) If no videos if (!videos || videos.length === 0) { this.videoList.innerHTML = `

@@ -1053,30 +1103,25 @@ class bitvidApp {

`; 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 - )}`; + const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(nevent)}`; const canEdit = video.pubkey === this.pubkey; const highlightClass = video.isPrivate && canEdit @@ -1084,7 +1129,7 @@ class bitvidApp { : "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); @@ -1101,7 +1146,6 @@ class bitvidApp { ` : ""; - // Gear menu (only shown if canEdit) const gearMenu = canEdit ? `
@@ -1140,9 +1184,9 @@ class bitvidApp { ` : ""; - // Card markup const cardHtml = `
+
+

${timeAgo} +

@@ -1191,15 +1237,13 @@ class bitvidApp {
`; - // 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); @@ -1207,14 +1251,8 @@ class bitvidApp { const lazyEls = this.videoList.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => this.mediaLoader.observe(el)); - // ------------------------------- - // Gear menu / button event listeners - // ------------------------------- - - // Toggle the gear menu - const gearButtons = this.videoList.querySelectorAll( - "[data-settings-dropdown]" - ); + // GEAR MENU / button event listeners... + const gearButtons = this.videoList.querySelectorAll("[data-settings-dropdown]"); gearButtons.forEach((button) => { button.addEventListener("click", () => { const index = button.getAttribute("data-settings-dropdown"); @@ -1225,43 +1263,33 @@ class bitvidApp { }); }); - // Edit button - const editButtons = this.videoList.querySelectorAll("[data-edit-index]"); - editButtons.forEach((button) => { - button.addEventListener("click", () => { - const index = button.getAttribute("data-edit-index"); - const dropdown = document.getElementById(`settingsDropdown-${index}`); - if (dropdown) dropdown.classList.add("hidden"); - this.handleEditVideo(index); - }); - }); - - // Revert button - const revertButtons = this.videoList.querySelectorAll("[data-revert-index]"); - revertButtons.forEach((button) => { - button.addEventListener("click", () => { - const index = button.getAttribute("data-revert-index"); - const dropdown = document.getElementById(`settingsDropdown-${index}`); - if (dropdown) dropdown.classList.add("hidden"); - this.handleRevertVideo(index); - }); - }); - - // Delete All button - const deleteAllButtons = this.videoList.querySelectorAll( - "[data-delete-all-index]" - ); - deleteAllButtons.forEach((button) => { - button.addEventListener("click", () => { - const index = button.getAttribute("data-delete-all-index"); - const dropdown = document.getElementById(`settingsDropdown-${index}`); - if (dropdown) dropdown.classList.add("hidden"); - this.handleFullDeleteVideo(index); - }); - }); + // Edit, Revert, Delete events omitted for brevity... // 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); + }); + }); } /** diff --git a/js/channelProfile.js b/js/channelProfile.js new file mode 100644 index 0000000..5325cac --- /dev/null +++ b/js/channelProfile.js @@ -0,0 +1,403 @@ +// js/channelProfile.js + +import { nostrClient } from "./nostr.js"; +import { app } from "./app.js"; +import { initialBlacklist, initialWhitelist } from "./lists.js"; +import { isWhitelistEnabled } from "./config.js"; + +/** + * Initialize the channel profile view. + * Called when #view=channel-profile is active. + */ +export async function initChannelProfileView() { + // 1) Get npub from hash (e.g. #view=channel-profile&npub=...) + const hashParams = new URLSearchParams(window.location.hash.slice(1)); + const npub = hashParams.get("npub"); + if (!npub) { + console.error("No npub found in hash. Example: #view=channel-profile&npub=npub1..."); + return; + } + + // 2) Decode npub => hex pubkey + let hexPub; + try { + const decoded = window.NostrTools.nip19.decode(npub); + if (decoded.type === "npub" && decoded.data) { + hexPub = decoded.data; + } else { + throw new Error("Invalid npub decoding result."); + } + } catch (err) { + console.error("Error decoding npub:", err); + return; + } + + // 3) Load user’s profile (banner, avatar, etc.) + await loadUserProfile(hexPub); + + // 4) Load user’s videos (filtered and rendered like home feed) + await loadUserVideos(hexPub); +} + +/** + * Fetches and displays the user’s metadata (kind:0). + */ +async function loadUserProfile(pubkey) { + try { + const events = await nostrClient.pool.list(nostrClient.relays, [ + { kinds: [0], authors: [pubkey], limit: 1 }, + ]); + + if (events.length && events[0].content) { + const meta = JSON.parse(events[0].content); + + // Banner + const bannerEl = document.getElementById("channelBanner"); + if (bannerEl) { + bannerEl.src = meta.banner || "assets/jpg/default-banner.jpg"; + } + + // Avatar + const avatarEl = document.getElementById("channelAvatar"); + if (avatarEl) { + avatarEl.src = meta.picture || "assets/svg/default-profile.svg"; + } + + // Channel Name + const nameEl = document.getElementById("channelName"); + if (nameEl) { + nameEl.textContent = meta.display_name || meta.name || "Unknown User"; + } + + // Channel npub + const channelNpubEl = document.getElementById("channelNpub"); + if (channelNpubEl) { + const userNpub = window.NostrTools.nip19.npubEncode(pubkey); + channelNpubEl.textContent = userNpub; + } + + // About/Description + const aboutEl = document.getElementById("channelAbout"); + if (aboutEl) { + aboutEl.textContent = meta.about || ""; + } + + // Website + const websiteEl = document.getElementById("channelWebsite"); + if (websiteEl) { + if (meta.website) { + websiteEl.href = meta.website; + websiteEl.textContent = meta.website; + } else { + websiteEl.textContent = ""; + websiteEl.removeAttribute("href"); + } + } + + // Lightning Address + const lnEl = document.getElementById("channelLightning"); + if (lnEl) { + lnEl.textContent = meta.lud16 || meta.lud06 || "No lightning address found."; + } + } else { + console.warn("No metadata found for this user."); + } + } catch (err) { + console.error("Failed to fetch user profile data:", err); + } +} + +/** + * Fetches and displays this user's videos (kind=30078), + * filtering out older/overshadowed events and blacklisted/non‐whitelisted entries. + * Renders the video cards using the same `.ratio-16-9` approach as the home view. + */ +async function loadUserVideos(pubkey) { + try { + // 1) Build filter for videos from this pubkey + const filter = { + kinds: [30078], + authors: [pubkey], + "#t": ["video"], + limit: 200, + }; + + // 2) Collect raw events from all relays + const events = []; + for (const url of nostrClient.relays) { + try { + const result = await nostrClient.pool.list([url], [filter]); + events.push(...result); + } catch (relayErr) { + console.error(`Relay error (${url}):`, relayErr); + } + } + + // 3) Convert events to "video" objects + let videos = []; + for (const evt of events) { + const vid = localConvertEventToVideo(evt); + if (!vid.invalid && !vid.deleted) { + videos.push(vid); + } + } + + // 4) Deduplicate older overshadowed versions (newest only) + videos = dedupeToNewestByRoot(videos); + + // 5) Filter out blacklisted event IDs and authors + videos = videos.filter((video) => { + // Event-level blacklisting + if (app.blacklistedEventIds.has(video.id)) { + return false; + } + // Author-level checks + const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey; + if (initialBlacklist.includes(authorNpub)) { + return false; + } + if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) { + return false; + } + return true; + }); + + // 6) Sort videos by newest first + videos.sort((a, b) => b.created_at - a.created_at); + + // 7) Render the videos in #channelVideoList + const container = document.getElementById("channelVideoList"); + if (!container) { + console.warn("channelVideoList element not found in DOM."); + return; + } + container.innerHTML = ""; + if (!videos.length) { + container.innerHTML = `

No videos to display.

`; + return; + } + + const fragment = document.createDocumentFragment(); + // We'll store them in a local array so gear handlers match the indexes + const channelVideos = videos; + + channelVideos.forEach((video, index) => { + // If private + the user is the owner => decrypt + if ( + video.isPrivate && + video.pubkey === nostrClient.pubkey && + !video.alreadyDecrypted + ) { + video.magnet = fakeDecrypt(video.magnet); + video.alreadyDecrypted = true; + } + + // Determine if the logged-in user can edit + const canEdit = video.pubkey === app.pubkey; + let gearMenu = ""; + if (canEdit) { + gearMenu = ` +
+ + +
+ `; + } + + // Reuse the `.ratio-16-9` approach from home feed + const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg"; + const safeThumb = video.thumbnail || fallbackThumb; + + // Build the card + const cardEl = document.createElement("div"); + cardEl.classList.add( + "bg-gray-900", + "rounded-lg", + "overflow-hidden", + "shadow-lg", + "hover:shadow-2xl", + "transition-all", + "duration-300" + ); + + // The "ratio-16-9" container ensures 16:9 cropping + cardEl.innerHTML = ` +
+
+ ${escapeHTML(video.title)} +
+
+
+
+

+ ${escapeHTML(video.title)} +

+

+ ${new Date(video.created_at * 1000).toLocaleString()} +

+
+ ${gearMenu} +
+ `; + + // Clicking the card (except gear) => open video + cardEl.addEventListener("click", () => { + app.playVideoByEventId(video.id); + }); + + fragment.appendChild(cardEl); + }); + + container.appendChild(fragment); + + // Use app's lazy loader for thumbs + const lazyEls = container.querySelectorAll("[data-lazy]"); + lazyEls.forEach((el) => app.mediaLoader.observe(el)); + + // Gear menus + const gearButtons = container.querySelectorAll("[data-settings-dropdown]"); + gearButtons.forEach((btn) => { + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + const idx = btn.getAttribute("data-settings-dropdown"); + const dropdown = document.getElementById(`settingsDropdown-${idx}`); + if (dropdown) { + dropdown.classList.toggle("hidden"); + } + }); + }); + + // "Edit" button + const editBtns = container.querySelectorAll("[data-edit-index]"); + editBtns.forEach((btn) => { + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + const idx = parseInt(btn.getAttribute("data-edit-index"), 10); + app.handleEditVideo(idx); + }); + }); + + // "Delete All" button + const deleteAllBtns = container.querySelectorAll("[data-delete-all-index]"); + deleteAllBtns.forEach((btn) => { + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10); + app.handleFullDeleteVideo(idx); + }); + }); + } catch (err) { + console.error("Error loading user videos:", err); + } +} + +/** + * Minimal placeholder decryption for private videos. + */ +function fakeDecrypt(str) { + return str.split("").reverse().join(""); +} + +/** + * Deduplicate older overshadowed versions – return only the newest for each root. + */ +function dedupeToNewestByRoot(videos) { + const map = new Map(); + for (const vid of videos) { + const rootId = vid.videoRootId || vid.id; + const existing = map.get(rootId); + if (!existing || vid.created_at > existing.created_at) { + map.set(rootId, vid); + } + } + return Array.from(map.values()); +} + +/** + * Convert a raw Nostr event => "video" object. + */ +function localConvertEventToVideo(event) { + try { + const content = JSON.parse(event.content || "{}"); + const isSupportedVersion = content.version >= 2; + const hasRequiredFields = !!(content.title && content.magnet); + + if (!isSupportedVersion) { + return { id: event.id, invalid: true, reason: "version <2" }; + } + if (!hasRequiredFields) { + return { id: event.id, invalid: true, reason: "missing title/magnet" }; + } + + return { + id: event.id, + videoRootId: content.videoRootId || event.id, + version: content.version, + isPrivate: content.isPrivate ?? false, + title: content.title ?? "", + magnet: content.magnet ?? "", + thumbnail: content.thumbnail ?? "", + description: content.description ?? "", + mode: content.mode ?? "live", + deleted: content.deleted === true, + pubkey: event.pubkey, + created_at: event.created_at, + tags: event.tags, + invalid: false, + }; + } catch (err) { + return { id: event.id, invalid: true, reason: "json parse error" }; + } +} + +/** + * Escape HTML to prevent injection or XSS. + */ +function escapeHTML(unsafe = "") { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/js/index.js b/js/index.js index 4cba2d8..ce3b6db 100644 --- a/js/index.js +++ b/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]; diff --git a/js/viewManager.js b/js/viewManager.js index e6b5c5e..00f3258 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -1,7 +1,9 @@ // js/viewManager.js +import { initChannelProfileView } from "./channelProfile.js"; -// Load a partial view by URL into the #viewContainer -// js/viewManager.js +/** + * Load a partial view by URL into the #viewContainer. + */ export async function loadView(viewUrl) { try { const res = await fetch(viewUrl); @@ -10,14 +12,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 +36,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(); } @@ -51,5 +56,8 @@ export const viewInitRegistry = { subscriptions: () => { console.log("Subscriptions view loaded."); }, - // Add additional view-specific functions here as needed. + "channel-profile": () => { + // Call the initialization function from channelProfile.js + initChannelProfileView(); + } }; diff --git a/views/channel-profile.html b/views/channel-profile.html new file mode 100644 index 0000000..0ca2e7b --- /dev/null +++ b/views/channel-profile.html @@ -0,0 +1,52 @@ +
+ +
+ + Banner +
+ +
+
+ Avatar +
+
+

User Name

+

npub...

+
+
+
+ +
+

+ Website +

+
+ +
+ +

Videos by This User

+ +
+ +
+
+ \ No newline at end of file From d5754ef4dafab0e1ac194b01cad8049f9b167feb Mon Sep 17 00:00:00 2001 From: Keep Creating Online Date: Sun, 9 Feb 2025 15:37:52 -0500 Subject: [PATCH 2/9] fixed edit, revert and delete on home video grid --- js/app.js | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index 6ce8d4b..7769170 100644 --- a/js/app.js +++ b/js/app.js @@ -1263,7 +1263,38 @@ class bitvidApp { }); }); - // Edit, Revert, Delete events omitted for brevity... + // Edit button + const editButtons = this.videoList.querySelectorAll("[data-edit-index]"); + editButtons.forEach((button) => { + button.addEventListener("click", () => { + const index = button.getAttribute("data-edit-index"); + const dropdown = document.getElementById(`settingsDropdown-${index}`); + if (dropdown) dropdown.classList.add("hidden"); + this.handleEditVideo(index); + }); + }); + + // Revert button + const revertButtons = this.videoList.querySelectorAll("[data-revert-index]"); + revertButtons.forEach((button) => { + button.addEventListener("click", () => { + const index = button.getAttribute("data-revert-index"); + const dropdown = document.getElementById(`settingsDropdown-${index}`); + if (dropdown) dropdown.classList.add("hidden"); + this.handleRevertVideo(index); + }); + }); + + // Delete All button + const deleteAllButtons = this.videoList.querySelectorAll("[data-delete-all-index]"); + deleteAllButtons.forEach((button) => { + button.addEventListener("click", () => { + const index = button.getAttribute("data-delete-all-index"); + const dropdown = document.getElementById(`settingsDropdown-${index}`); + if (dropdown) dropdown.classList.add("hidden"); + this.handleFullDeleteVideo(index); + }); + }); // 2) After building cards, do one batch profile fetch this.batchFetchProfiles(authorSet); From 5380d0261f0adec215a6f0bfa4fb0593f564783a Mon Sep 17 00:00:00 2001 From: Keep Creating Online Date: Sun, 9 Feb 2025 15:45:02 -0500 Subject: [PATCH 3/9] added "revert" option to profile grid list --- js/channelProfile.js | 94 +++++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 27 deletions(-) diff --git a/js/channelProfile.js b/js/channelProfile.js index 5325cac..05a12c7 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -14,7 +14,9 @@ export async function initChannelProfileView() { const hashParams = new URLSearchParams(window.location.hash.slice(1)); const npub = hashParams.get("npub"); if (!npub) { - console.error("No npub found in hash. Example: #view=channel-profile&npub=npub1..."); + console.error( + "No npub found in hash. Example: #view=channel-profile&npub=npub1..." + ); return; } @@ -35,12 +37,12 @@ export async function initChannelProfileView() { // 3) Load user’s profile (banner, avatar, etc.) await loadUserProfile(hexPub); - // 4) Load user’s videos (filtered and rendered like home feed) + // 4) Load user’s videos (filtered + rendered like the home feed) await loadUserVideos(hexPub); } /** - * Fetches and displays the user’s metadata (kind:0). + * Fetches and displays the user’s metadata (kind=0). */ async function loadUserProfile(pubkey) { try { @@ -97,7 +99,8 @@ async function loadUserProfile(pubkey) { // Lightning Address const lnEl = document.getElementById("channelLightning"); if (lnEl) { - lnEl.textContent = meta.lud16 || meta.lud06 || "No lightning address found."; + lnEl.textContent = + meta.lud16 || meta.lud06 || "No lightning address found."; } } else { console.warn("No metadata found for this user."); @@ -109,8 +112,7 @@ async function loadUserProfile(pubkey) { /** * Fetches and displays this user's videos (kind=30078), - * filtering out older/overshadowed events and blacklisted/non‐whitelisted entries. - * Renders the video cards using the same `.ratio-16-9` approach as the home view. + * filtering out older overshadowed notes, blacklisted, non‐whitelisted, etc. */ async function loadUserVideos(pubkey) { try { @@ -133,7 +135,7 @@ async function loadUserVideos(pubkey) { } } - // 3) Convert events to "video" objects + // 3) Convert to "video" objects let videos = []; for (const evt of events) { const vid = localConvertEventToVideo(evt); @@ -142,30 +144,27 @@ async function loadUserVideos(pubkey) { } } - // 4) Deduplicate older overshadowed versions (newest only) + // 4) Deduplicate older overshadowed versions => newest only videos = dedupeToNewestByRoot(videos); - // 5) Filter out blacklisted event IDs and authors + // 5) Filter out blacklisted IDs / authors videos = videos.filter((video) => { // Event-level blacklisting - if (app.blacklistedEventIds.has(video.id)) { - return false; - } - // Author-level checks + 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 (initialBlacklist.includes(authorNpub)) return false; if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) { return false; } return true; }); - // 6) Sort videos by newest first + // 6) Sort newest first videos.sort((a, b) => b.created_at - a.created_at); - // 7) Render the videos in #channelVideoList + // 7) Render them const container = document.getElementById("channelVideoList"); if (!container) { console.warn("channelVideoList element not found in DOM."); @@ -178,11 +177,13 @@ async function loadUserVideos(pubkey) { } const fragment = document.createDocumentFragment(); - // We'll store them in a local array so gear handlers match the indexes const channelVideos = videos; + // We'll need all known events for revert-check + const allKnownEventsArray = Array.from(nostrClient.allEvents.values()); + channelVideos.forEach((video, index) => { - // If private + the user is the owner => decrypt + // Private => decrypt if owned by the user if ( video.isPrivate && video.pubkey === nostrClient.pubkey && @@ -192,8 +193,28 @@ async function loadUserVideos(pubkey) { video.alreadyDecrypted = true; } - // Determine if the logged-in user can edit + // Check if user can edit const canEdit = video.pubkey === app.pubkey; + let hasOlder = false; + if (canEdit && video.videoRootId) { + // Use the same hasOlderVersion approach as home feed + hasOlder = app.hasOlderVersion(video, allKnownEventsArray); + } + + // If there's an older overshadowed version, show revert + const revertButton = hasOlder + ? ` + + ` + : ""; + + // Gear menu let gearMenu = ""; if (canEdit) { gearMenu = ` @@ -224,6 +245,7 @@ async function loadUserVideos(pubkey) { > Edit + ${revertButton} + `; + + 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 { @@ -111,8 +172,8 @@ async function loadUserProfile(pubkey) { } /** - * Fetches and displays this user's videos (kind=30078), - * filtering out older overshadowed notes, blacklisted, non‐whitelisted, etc. + * Fetches and displays this user's videos (kind=30078). + * Filters out older overshadowed notes, blacklisted, etc. */ async function loadUserVideos(pubkey) { try { @@ -177,13 +238,10 @@ async function loadUserVideos(pubkey) { } const fragment = document.createDocumentFragment(); - const channelVideos = videos; - - // We'll need all known events for revert-check const allKnownEventsArray = Array.from(nostrClient.allEvents.values()); - channelVideos.forEach((video, index) => { - // Private => decrypt if owned by the user + videos.forEach((video, index) => { + // Decrypt if user owns a private video if ( video.isPrivate && video.pubkey === nostrClient.pubkey && @@ -197,15 +255,13 @@ async function loadUserVideos(pubkey) { const canEdit = video.pubkey === app.pubkey; let hasOlder = false; if (canEdit && video.videoRootId) { - // Use the same hasOlderVersion approach as home feed hasOlder = app.hasOlderVersion(video, allKnownEventsArray); } - // If there's an older overshadowed version, show revert const revertButton = hasOlder ? `
-

- ${escapeHTML(video.title)} + ${safeTitle}

${new Date(video.created_at * 1000).toLocaleString()} @@ -301,7 +356,7 @@ async function loadUserVideos(pubkey) {

`; - // Clicking the card (except gear) => open video + // Clicking the card => open the video modal cardEl.addEventListener("click", () => { app.playVideoByEventId(video.id); }); @@ -311,7 +366,7 @@ async function loadUserVideos(pubkey) { container.appendChild(fragment); - // Lazy-load + // Lazy-load images const lazyEls = container.querySelectorAll("[data-lazy]"); 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]"); editBtns.forEach((btn) => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const idx = parseInt(btn.getAttribute("data-edit-index"), 10); - // Hide the dropdown const dropdown = document.getElementById(`settingsDropdown-${idx}`); if (dropdown) dropdown.classList.add("hidden"); app.handleEditVideo(idx); }); }); - // "Revert" handler + // 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); - // Hide the dropdown const dropdown = document.getElementById(`settingsDropdown-${idx}`); if (dropdown) dropdown.classList.add("hidden"); app.handleRevertVideo(idx); }); }); - // "Delete All" handler + // 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); - // Hide the dropdown const dropdown = document.getElementById(`settingsDropdown-${idx}`); if (dropdown) dropdown.classList.add("hidden"); 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) { 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) { 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) { try { @@ -431,7 +483,7 @@ function localConvertEventToVideo(event) { } /** - * Escape HTML to prevent injection or XSS. + * Basic escaping to avoid XSS. */ function escapeHTML(unsafe = "") { return unsafe diff --git a/js/subscriptions.js b/js/subscriptions.js new file mode 100644 index 0000000..802c5b0 --- /dev/null +++ b/js/subscriptions.js @@ -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 = "

Please log in first.

"; + 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 = + "

No subscriptions found.

"; + 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 = ` +

+ No videos available yet. +

`; + 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 + ? ` + + ` + : ""; + + const gearMenu = canEdit + ? ` +
+ + +
+ ` + : ""; + + const safeTitle = window.app?.escapeHTML(video.title) || "Untitled"; + const safeThumb = window.app?.escapeHTML(video.thumbnail) || ""; + const cardHtml = ` +
+ +
+ ${safeTitle} +
+
+
+

+ ${safeTitle} +

+
+
+
+ Placeholder +
+
+

+ Loading name... +

+
+ ${timeAgo} +
+
+
+ ${gearMenu} +
+
+
+ `; + + 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(); diff --git a/js/viewManager.js b/js/viewManager.js index 00f3258..2589e46 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -1,5 +1,6 @@ // js/viewManager.js import { initChannelProfileView } from "./channelProfile.js"; +import { subscriptions } from "./subscriptions.js"; /** * Load a partial view by URL into the #viewContainer. @@ -53,11 +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 = + "

Please log in to see your subscriptions.

"; + } + 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(); - } + }, }; diff --git a/views/channel-profile.html b/views/channel-profile.html index 0ca2e7b..693c7dd 100644 --- a/views/channel-profile.html +++ b/views/channel-profile.html @@ -1,52 +1,58 @@
- -
- - Banner -
- -
-
- Avatar -
-
-

User Name

-

npub...

-
+ +
+ + Banner +
+ +
+
+ Avatar +
+
+

User Name

+

npub...

- -
-

- Website -

-
- -
- -

Videos by This User

- -
+ + +
+

+ Website - -
-
- \ No newline at end of file +

+
+ + +
+ +
+ +
+ +

Videos by This User

+
+ +
+ diff --git a/views/subscriptions.html b/views/subscriptions.html index 38545fa..309018a 100644 --- a/views/subscriptions.html +++ b/views/subscriptions.html @@ -1,5 +1,13 @@
-

Subscriptions

-

Coming Soon...

+

Subscriptions

+
From e5ce98db07198d11e1e70f6d7c46d15bb4836ad9 Mon Sep 17 00:00:00 2001 From: Keep Creating Online <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 10 Feb 2025 08:01:01 -0500 Subject: [PATCH 9/9] updated gear icon on profile page and removed "make private" option until i can get it working correctly --- components/upload-modal.html | 4 +- content/roadmap.md | 23 +++++++++ js/app.js | 94 ++++++++++++++++++++---------------- js/channelProfile.js | 9 ++-- 4 files changed, 82 insertions(+), 48 deletions(-) diff --git a/components/upload-modal.html b/components/upload-modal.html index 368a420..fa01051 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -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" > - +