bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

commit 01be0ea041b9f7b8077f4fa196bdcdc93be4d8a0
parent b29dce0e46364d740b5fecd5811bc33a7736ab08
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Mon, 16 Feb 2026 23:15:40 -0700

UI polish

first round of UI polish

Diffstat:
MCLEAN_INSTALL/public/app.js | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCLEAN_INSTALL/public/index.html | 28++++++++++++++++++++++++++++
MCLEAN_INSTALL/public/styles.css | 36++++++++++++++++++++++++++++++++++++
Mpublic/app.js | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpublic/index.html | 28++++++++++++++++++++++++++++
Mpublic/styles.css | 36++++++++++++++++++++++++++++++++++++
6 files changed, 464 insertions(+), 0 deletions(-)

diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js @@ -19,6 +19,7 @@ const mobilePagerEl = document.getElementById("mobilePager"); const mobileModBtn = document.getElementById("mobileModBtn"); const enableNotifsBtn = document.getElementById("enableNotifs"); const notifStatus = document.getElementById("notifStatus"); +const toggleReactionsEl = document.getElementById("toggleReactions"); const authHint = document.getElementById("authHint"); const userLabel = document.getElementById("userLabel"); @@ -57,6 +58,13 @@ const modModalPrimary = document.getElementById("modModalPrimary"); const modModalCancel = document.getElementById("modModalCancel"); const modModalClose = document.getElementById("modModalClose"); const modModalStatus = document.getElementById("modModalStatus"); +const mediaModal = document.getElementById("mediaModal"); +const mediaModalTitle = document.getElementById("mediaModalTitle"); +const mediaModalImg = document.getElementById("mediaModalImg"); +const mediaModalOpenLink = document.getElementById("mediaModalOpenLink"); +const mediaModalCopyLink = document.getElementById("mediaModalCopyLink"); +const mediaModalClose = document.getElementById("mediaModalClose"); +const mediaModalStatus = document.getElementById("mediaModalStatus"); const newPostForm = document.getElementById("newPostForm"); const pollinatePanel = document.getElementById("pollinatePanel"); @@ -206,6 +214,7 @@ const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024; let allowedPostReactions = ["👍", "❤️", "😡", "😭", "🥺", "😂", "⭐"]; let allowedChatReactions = ["👍", "❤️", "😡", "😭", "🥺", "😂"]; let userPrefs = { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] }; +let showReactions = localStorage.getItem("bzl_showReactions") !== "0"; let activeHiveView = "all"; let collections = []; let customRoles = []; @@ -365,6 +374,27 @@ function setModModalOpen(open) { } } +function setMediaModalOpen(open) { + if (!mediaModal) return; + mediaModal.classList.toggle("hidden", !open); + if (!open) { + if (mediaModalImg) mediaModalImg.src = ""; + if (mediaModalOpenLink) mediaModalOpenLink.href = "#"; + if (mediaModalStatus) mediaModalStatus.textContent = ""; + if (mediaModalTitle) mediaModalTitle.textContent = "Media"; + } +} + +function openMediaModal(url) { + const src = String(url || "").trim(); + if (!src) return; + if (!mediaModalImg) return; + mediaModalImg.src = src; + if (mediaModalOpenLink) mediaModalOpenLink.href = src; + if (mediaModalStatus) mediaModalStatus.textContent = ""; + setMediaModalOpen(true); +} + function gateTokenLabel(token) { const t = String(token || "").trim().toLowerCase(); if (!t) return { label: "", color: "" }; @@ -1959,6 +1989,7 @@ function markReactPulse(kind, id, emoji) { } function renderReactionButtons({ kind, id, reactions, postId }) { + if (!showReactions) return ""; const r = reactions && typeof reactions === "object" ? reactions : {}; const emojis = kind === "post" ? allowedPostReactions : allowedChatReactions; return `<div class="reactionsRow"> @@ -2725,6 +2756,7 @@ function renderModPanel() { } function renderChatPanel(forceScroll = false) { + const mediaState = captureMediaState(chatMessagesEl); if (activeDmThreadId) { const thread = dmThreadsById.get(activeDmThreadId) || null; if (!thread) { @@ -2756,6 +2788,7 @@ function renderChatPanel(forceScroll = false) { ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(thread.other)}">Request again</button>` : `<div class="muted">Waiting for @${escapeHtml(thread.other)}…</div>`; chatMessagesEl.innerHTML = `<div class="small muted">${promptHtml}</div>`; + restoreMediaState(chatMessagesEl, mediaState); setReplyToMessage(null); return; } @@ -2781,6 +2814,7 @@ function renderChatPanel(forceScroll = false) { decorateMentionNodesInElement(contentEl); decorateYouTubeEmbedsInElement(contentEl); } + restoreMediaState(chatMessagesEl, mediaState); if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; return; } @@ -2794,6 +2828,7 @@ function renderChatPanel(forceScroll = false) { if (walkieBarEl) walkieBarEl.classList.add("hidden"); if (chatForm) chatForm.classList.remove("hidden"); chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div>`; + restoreMediaState(chatMessagesEl, mediaState); setReplyToMessage(null); return; } @@ -2820,6 +2855,7 @@ function renderChatPanel(forceScroll = false) { if (chatSendBtn) chatSendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie); if (post.deleted) { chatMessagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`; + restoreMediaState(chatMessagesEl, mediaState); setReplyToMessage(null); return; } @@ -2898,9 +2934,50 @@ function renderChatPanel(forceScroll = false) { decorateMentionNodesInElement(contentEl); decorateYouTubeEmbedsInElement(contentEl); } + restoreMediaState(chatMessagesEl, mediaState); if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; } +function captureMediaState(containerEl) { + if (!containerEl) return []; + const list = []; + for (const el of containerEl.querySelectorAll("audio, video")) { + try { + const src = el.currentSrc || el.getAttribute("src") || ""; + if (!src) continue; + list.push({ + src, + currentTime: Number(el.currentTime || 0), + paused: Boolean(el.paused), + volume: Number.isFinite(el.volume) ? el.volume : 1, + playbackRate: Number.isFinite(el.playbackRate) ? el.playbackRate : 1 + }); + } catch { + // ignore + } + } + return list; +} + +function restoreMediaState(containerEl, mediaState) { + if (!containerEl || !Array.isArray(mediaState) || mediaState.length === 0) return; + const els = Array.from(containerEl.querySelectorAll("audio, video")); + for (const s of mediaState) { + const src = String(s?.src || ""); + if (!src) continue; + const el = els.find((x) => (x.currentSrc || x.getAttribute("src") || "") === src); + if (!el) continue; + try { + if (Number.isFinite(s.volume)) el.volume = s.volume; + if (Number.isFinite(s.playbackRate)) el.playbackRate = s.playbackRate; + if (Number.isFinite(s.currentTime)) el.currentTime = s.currentTime; + if (!s.paused) el.play().catch(() => {}); + } catch { + // ignore + } + } +} + function appendChatHtmlAndDecorate(html, atBottomBefore) { if (!chatMessagesEl) return null; chatMessagesEl.insertAdjacentHTML("beforeend", html); @@ -3535,6 +3612,48 @@ function insertAudioTag(target, srcUrl) { document.execCommand("insertHTML", false, `<audio controls preload="none" src="${safe}"></audio>`); } +function installDropUpload(targetEl, { allowImages = true, allowAudio = true } = {}) { + if (!targetEl) return; + const setActive = (on) => { + try { + targetEl.classList.toggle("isDropActive", Boolean(on)); + } catch { + // ignore + } + }; + targetEl.addEventListener("dragover", (e) => { + if (!e.dataTransfer) return; + if (!e.dataTransfer.types || !Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + setActive(true); + }); + targetEl.addEventListener("dragleave", () => setActive(false)); + targetEl.addEventListener("drop", async (e) => { + setActive(false); + const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : []; + if (!files.length) return; + e.preventDefault(); + e.stopPropagation(); + + for (const file of files.slice(0, 4)) { + const type = String(file.type || "").toLowerCase(); + const name = String(file.name || "").toLowerCase(); + const isImg = type.startsWith("image/") || /\.(gif|png|jpe?g|webp)$/.test(name); + const isAud = type.startsWith("audio/") || /\.(mp3|wav|ogg|m4a|aac|webm)$/.test(name); + if (isImg && allowImages) { + const url = await uploadMediaFile(file, "image"); + if (!url) continue; + targetEl.focus(); + document.execCommand("insertImage", false, url); + } else if (isAud && allowAudio) { + const url = await uploadMediaFile(file, "audio"); + if (!url) continue; + insertAudioTag(targetEl, url); + } + } + }); +} + document.querySelector(".editorShell .toolbar")?.addEventListener("click", (e) => { const btn = e.target.closest("button"); if (!btn) return; @@ -5305,6 +5424,55 @@ applyChatWidth(readStoredChatWidth(), false); applyModWidth(readStoredModWidth(), false); applyPeopleWidth(readStoredPeopleWidth(), false); +if (toggleReactionsEl) { + toggleReactionsEl.checked = showReactions; + toggleReactionsEl.addEventListener("change", () => { + showReactions = Boolean(toggleReactionsEl.checked); + localStorage.setItem("bzl_showReactions", showReactions ? "1" : "0"); + renderFeed(); + renderChatPanel(); + }); +} + +installDropUpload(editor, { allowImages: true, allowAudio: true }); +installDropUpload(chatEditor, { allowImages: true, allowAudio: true }); +installDropUpload(profileBioEditor, { allowImages: true, allowAudio: true }); +installDropUpload(editModalEditor, { allowImages: true, allowAudio: true }); + +mediaModal?.addEventListener("click", (e) => { + if (e.target?.getAttribute?.("data-mediamodalclose")) setMediaModalOpen(false); +}); +mediaModalClose?.addEventListener("click", () => setMediaModalOpen(false)); +mediaModalCopyLink?.addEventListener("click", async () => { + const url = String(mediaModalOpenLink?.href || "").trim(); + if (!url || url === "#") return; + try { + await navigator.clipboard.writeText(url); + if (mediaModalStatus) mediaModalStatus.textContent = "Copied."; + } catch { + if (mediaModalStatus) mediaModalStatus.textContent = "Copy failed (clipboard blocked)."; + } +}); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && mediaModal && !mediaModal.classList.contains("hidden")) setMediaModalOpen(false); +}); +document.body.addEventListener("click", (e) => { + const img = e.target?.closest?.("img"); + if (!img) return; + if (img.id === "profilePreview") return; + if (img.closest("#mediaModal")) return; + const inAllowed = + img.closest(".chatMsg .content") || + img.closest(".profileBio") || + img.closest(".profileCard") || + img.closest(".editor") || + img.closest("#editModalEditor"); + if (!inAllowed) return; + const src = img.getAttribute("src") || ""; + if (!src) return; + openMediaModal(src); +}); + setSidebarHidden(getSidebarHidden()); toggleSidebarBtn?.addEventListener("click", () => setSidebarHidden(true)); showSidebarBtn?.addEventListener("click", () => setSidebarHidden(false)); diff --git a/CLEAN_INSTALL/public/index.html b/CLEAN_INSTALL/public/index.html @@ -26,6 +26,14 @@ </div> <section class="panel"> + <div class="panelTitle">View</div> + <label class="checkRow"> + <span>Show reactions bar</span> + <input id="toggleReactions" type="checkbox" /> + </label> + </section> + + <section class="panel"> <div class="panelTitle">Account</div> <div class="small muted" id="authHint">Sign in to post, chat, and boost.</div> @@ -462,6 +470,26 @@ </div> </div> + <div id="mediaModal" class="modal hidden" role="dialog" aria-modal="true" aria-label="Media preview"> + <div class="modalBackdrop" data-mediamodalclose="1"></div> + <div class="modalCard panel"> + <div class="panelHeader"> + <div class="panelTitle" id="mediaModalTitle">Media</div> + <div class="row"> + <button id="mediaModalClose" class="ghost smallBtn" type="button">Close</button> + </div> + </div> + <div class="modalBody" id="mediaModalBody"> + <img id="mediaModalImg" alt="Expanded media" /> + <div class="row modalActions" style="justify-content:flex-start"> + <a id="mediaModalOpenLink" class="ghost" href="#" target="_blank" rel="noreferrer">Open original</a> + <button id="mediaModalCopyLink" class="ghost" type="button">Copy link</button> + </div> + <div id="mediaModalStatus" class="small muted"></div> + </div> + </div> + </div> + <script src="/app.js?v=83"></script> </body> </html> diff --git a/CLEAN_INSTALL/public/styles.css b/CLEAN_INSTALL/public/styles.css @@ -1219,8 +1219,21 @@ button:disabled { .profileBio img { max-width: 100%; + max-height: min(420px, 55vh); + object-fit: contain; border-radius: 12px; display: block; + cursor: zoom-in; +} + +.editor img { + max-width: 100%; + max-height: min(420px, 55vh); + object-fit: contain; + border-radius: 12px; + display: block; + margin-top: 8px; + cursor: zoom-in; } .ytEmbed { @@ -1807,9 +1820,16 @@ button:disabled { .chatMsg .content img { max-width: 100%; + max-height: min(420px, 55vh); + object-fit: contain; border-radius: 10px; display: block; margin-top: 6px; + cursor: zoom-in; +} + +.chatReactions:empty { + display: none; } .chatMsg .content audio, @@ -1819,6 +1839,22 @@ button:disabled { margin-top: 8px; } +#mediaModalImg { + max-width: min(92vw, 1200px); + max-height: 78vh; + width: auto; + height: auto; + display: block; + margin: 0 auto; + border-radius: 14px; + background: rgba(0, 0, 0, 0.18); +} + +.editor.isDropActive { + outline: 2px dashed rgba(255, 62, 165, 0.7); + outline-offset: 6px; +} + .chatMsg .meta { font-size: 11px; color: var(--muted); diff --git a/public/app.js b/public/app.js @@ -19,6 +19,7 @@ const mobilePagerEl = document.getElementById("mobilePager"); const mobileModBtn = document.getElementById("mobileModBtn"); const enableNotifsBtn = document.getElementById("enableNotifs"); const notifStatus = document.getElementById("notifStatus"); +const toggleReactionsEl = document.getElementById("toggleReactions"); const authHint = document.getElementById("authHint"); const userLabel = document.getElementById("userLabel"); @@ -48,6 +49,13 @@ const modModalPrimary = document.getElementById("modModalPrimary"); const modModalCancel = document.getElementById("modModalCancel"); const modModalClose = document.getElementById("modModalClose"); const modModalStatus = document.getElementById("modModalStatus"); +const mediaModal = document.getElementById("mediaModal"); +const mediaModalTitle = document.getElementById("mediaModalTitle"); +const mediaModalImg = document.getElementById("mediaModalImg"); +const mediaModalOpenLink = document.getElementById("mediaModalOpenLink"); +const mediaModalCopyLink = document.getElementById("mediaModalCopyLink"); +const mediaModalClose = document.getElementById("mediaModalClose"); +const mediaModalStatus = document.getElementById("mediaModalStatus"); const newPostForm = document.getElementById("newPostForm"); const pollinatePanel = document.getElementById("pollinatePanel"); @@ -201,6 +209,7 @@ const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024; let allowedPostReactions = ["👍", "❤️", "😡", "😭", "🥺", "😂", "⭐"]; let allowedChatReactions = ["👍", "❤️", "😡", "😭", "🥺", "😂"]; let userPrefs = { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] }; +let showReactions = localStorage.getItem("bzl_showReactions") !== "0"; let activeHiveView = "all"; let collections = []; let customRoles = []; @@ -570,6 +579,27 @@ function setModModalOpen(open) { } } +function setMediaModalOpen(open) { + if (!mediaModal) return; + mediaModal.classList.toggle("hidden", !open); + if (!open) { + if (mediaModalImg) mediaModalImg.src = ""; + if (mediaModalOpenLink) mediaModalOpenLink.href = "#"; + if (mediaModalStatus) mediaModalStatus.textContent = ""; + if (mediaModalTitle) mediaModalTitle.textContent = "Media"; + } +} + +function openMediaModal(url) { + const src = String(url || "").trim(); + if (!src) return; + if (!mediaModalImg) return; + mediaModalImg.src = src; + if (mediaModalOpenLink) mediaModalOpenLink.href = src; + if (mediaModalStatus) mediaModalStatus.textContent = ""; + setMediaModalOpen(true); +} + function gateTokenLabel(token) { const t = String(token || "").trim().toLowerCase(); if (!t) return { label: "", color: "" }; @@ -2193,6 +2223,7 @@ function markReactPulse(kind, id, emoji) { } function renderReactionButtons({ kind, id, reactions, postId }) { + if (!showReactions) return ""; const r = reactions && typeof reactions === "object" ? reactions : {}; const emojis = kind === "post" ? allowedPostReactions : allowedChatReactions; return `<div class="reactionsRow"> @@ -3202,6 +3233,7 @@ function renderModPanel() { } function renderChatPanel(forceScroll = false) { + const mediaState = captureMediaState(chatMessagesEl); if (activeDmThreadId) { const thread = dmThreadsById.get(activeDmThreadId) || null; if (!thread) { @@ -3233,6 +3265,7 @@ function renderChatPanel(forceScroll = false) { ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(thread.other)}">Request again</button>` : `<div class="muted">Waiting for @${escapeHtml(thread.other)}…</div>`; chatMessagesEl.innerHTML = `<div class="small muted">${promptHtml}</div>`; + restoreMediaState(chatMessagesEl, mediaState); setReplyToMessage(null); return; } @@ -3258,6 +3291,7 @@ function renderChatPanel(forceScroll = false) { decorateMentionNodesInElement(contentEl); decorateYouTubeEmbedsInElement(contentEl); } + restoreMediaState(chatMessagesEl, mediaState); if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; return; } @@ -3271,6 +3305,7 @@ function renderChatPanel(forceScroll = false) { if (walkieBarEl) walkieBarEl.classList.add("hidden"); if (chatForm) chatForm.classList.remove("hidden"); chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div>`; + restoreMediaState(chatMessagesEl, mediaState); setReplyToMessage(null); return; } @@ -3297,6 +3332,7 @@ function renderChatPanel(forceScroll = false) { if (chatSendBtn) chatSendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie); if (post.deleted) { chatMessagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`; + restoreMediaState(chatMessagesEl, mediaState); setReplyToMessage(null); return; } @@ -3375,9 +3411,50 @@ function renderChatPanel(forceScroll = false) { decorateMentionNodesInElement(contentEl); decorateYouTubeEmbedsInElement(contentEl); } + restoreMediaState(chatMessagesEl, mediaState); if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; } +function captureMediaState(containerEl) { + if (!containerEl) return []; + const list = []; + for (const el of containerEl.querySelectorAll("audio, video")) { + try { + const src = el.currentSrc || el.getAttribute("src") || ""; + if (!src) continue; + list.push({ + src, + currentTime: Number(el.currentTime || 0), + paused: Boolean(el.paused), + volume: Number.isFinite(el.volume) ? el.volume : 1, + playbackRate: Number.isFinite(el.playbackRate) ? el.playbackRate : 1 + }); + } catch { + // ignore + } + } + return list; +} + +function restoreMediaState(containerEl, mediaState) { + if (!containerEl || !Array.isArray(mediaState) || mediaState.length === 0) return; + const els = Array.from(containerEl.querySelectorAll("audio, video")); + for (const s of mediaState) { + const src = String(s?.src || ""); + if (!src) continue; + const el = els.find((x) => (x.currentSrc || x.getAttribute("src") || "") === src); + if (!el) continue; + try { + if (Number.isFinite(s.volume)) el.volume = s.volume; + if (Number.isFinite(s.playbackRate)) el.playbackRate = s.playbackRate; + if (Number.isFinite(s.currentTime)) el.currentTime = s.currentTime; + if (!s.paused) el.play().catch(() => {}); + } catch { + // ignore + } + } +} + function appendChatHtmlAndDecorate(html, atBottomBefore) { if (!chatMessagesEl) return null; chatMessagesEl.insertAdjacentHTML("beforeend", html); @@ -4012,6 +4089,48 @@ function insertAudioTag(target, srcUrl) { document.execCommand("insertHTML", false, `<audio controls preload="none" src="${safe}"></audio>`); } +function installDropUpload(targetEl, { allowImages = true, allowAudio = true } = {}) { + if (!targetEl) return; + const setActive = (on) => { + try { + targetEl.classList.toggle("isDropActive", Boolean(on)); + } catch { + // ignore + } + }; + targetEl.addEventListener("dragover", (e) => { + if (!e.dataTransfer) return; + if (!e.dataTransfer.types || !Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + setActive(true); + }); + targetEl.addEventListener("dragleave", () => setActive(false)); + targetEl.addEventListener("drop", async (e) => { + setActive(false); + const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : []; + if (!files.length) return; + e.preventDefault(); + e.stopPropagation(); + + for (const file of files.slice(0, 4)) { + const type = String(file.type || "").toLowerCase(); + const name = String(file.name || "").toLowerCase(); + const isImg = type.startsWith("image/") || /\.(gif|png|jpe?g|webp)$/.test(name); + const isAud = type.startsWith("audio/") || /\.(mp3|wav|ogg|m4a|aac|webm)$/.test(name); + if (isImg && allowImages) { + const url = await uploadMediaFile(file, "image"); + if (!url) continue; + targetEl.focus(); + document.execCommand("insertImage", false, url); + } else if (isAud && allowAudio) { + const url = await uploadMediaFile(file, "audio"); + if (!url) continue; + insertAudioTag(targetEl, url); + } + } + }); +} + document.querySelector(".editorShell .toolbar")?.addEventListener("click", (e) => { const btn = e.target.closest("button"); if (!btn) return; @@ -6020,6 +6139,55 @@ applyChatWidth(readStoredChatWidth(), false); applyModWidth(readStoredModWidth(), false); applyPeopleWidth(readStoredPeopleWidth(), false); +if (toggleReactionsEl) { + toggleReactionsEl.checked = showReactions; + toggleReactionsEl.addEventListener("change", () => { + showReactions = Boolean(toggleReactionsEl.checked); + localStorage.setItem("bzl_showReactions", showReactions ? "1" : "0"); + renderFeed(); + renderChatPanel(); + }); +} + +installDropUpload(editor, { allowImages: true, allowAudio: true }); +installDropUpload(chatEditor, { allowImages: true, allowAudio: true }); +installDropUpload(profileBioEditor, { allowImages: true, allowAudio: true }); +installDropUpload(editModalEditor, { allowImages: true, allowAudio: true }); + +mediaModal?.addEventListener("click", (e) => { + if (e.target?.getAttribute?.("data-mediamodalclose")) setMediaModalOpen(false); +}); +mediaModalClose?.addEventListener("click", () => setMediaModalOpen(false)); +mediaModalCopyLink?.addEventListener("click", async () => { + const url = String(mediaModalOpenLink?.href || "").trim(); + if (!url || url === "#") return; + try { + await navigator.clipboard.writeText(url); + if (mediaModalStatus) mediaModalStatus.textContent = "Copied."; + } catch { + if (mediaModalStatus) mediaModalStatus.textContent = "Copy failed (clipboard blocked)."; + } +}); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && mediaModal && !mediaModal.classList.contains("hidden")) setMediaModalOpen(false); +}); +document.body.addEventListener("click", (e) => { + const img = e.target?.closest?.("img"); + if (!img) return; + if (img.id === "profilePreview") return; + if (img.closest("#mediaModal")) return; + const inAllowed = + img.closest(".chatMsg .content") || + img.closest(".profileBio") || + img.closest(".profileCard") || + img.closest(".editor") || + img.closest("#editModalEditor"); + if (!inAllowed) return; + const src = img.getAttribute("src") || ""; + if (!src) return; + openMediaModal(src); +}); + setSidebarHidden(getSidebarHidden()); toggleSidebarBtn?.addEventListener("click", () => setSidebarHidden(true)); showSidebarBtn?.addEventListener("click", () => setSidebarHidden(false)); diff --git a/public/index.html b/public/index.html @@ -26,6 +26,14 @@ </div> <section class="panel"> + <div class="panelTitle">View</div> + <label class="checkRow"> + <span>Show reactions bar</span> + <input id="toggleReactions" type="checkbox" /> + </label> + </section> + + <section class="panel"> <div class="panelTitle">Account</div> <div class="small muted" id="authHint">Sign in to post, chat, and boost.</div> @@ -438,6 +446,26 @@ </div> </div> + <div id="mediaModal" class="modal hidden" role="dialog" aria-modal="true" aria-label="Media preview"> + <div class="modalBackdrop" data-mediamodalclose="1"></div> + <div class="modalCard panel"> + <div class="panelHeader"> + <div class="panelTitle" id="mediaModalTitle">Media</div> + <div class="row"> + <button id="mediaModalClose" class="ghost smallBtn" type="button">Close</button> + </div> + </div> + <div class="modalBody" id="mediaModalBody"> + <img id="mediaModalImg" alt="Expanded media" /> + <div class="row modalActions" style="justify-content:flex-start"> + <a id="mediaModalOpenLink" class="ghost" href="#" target="_blank" rel="noreferrer">Open original</a> + <button id="mediaModalCopyLink" class="ghost" type="button">Copy link</button> + </div> + <div id="mediaModalStatus" class="small muted"></div> + </div> + </div> + </div> + <script src="/app.js?v=91"></script> </body> </html> diff --git a/public/styles.css b/public/styles.css @@ -1223,8 +1223,21 @@ button:disabled { .profileBio img { max-width: 100%; + max-height: min(420px, 55vh); + object-fit: contain; + border-radius: 12px; + display: block; + cursor: zoom-in; +} + +.editor img { + max-width: 100%; + max-height: min(420px, 55vh); + object-fit: contain; border-radius: 12px; display: block; + margin-top: 8px; + cursor: zoom-in; } .ytEmbed { @@ -1811,9 +1824,16 @@ button:disabled { .chatMsg .content img { max-width: 100%; + max-height: min(420px, 55vh); + object-fit: contain; border-radius: 10px; display: block; margin-top: 6px; + cursor: zoom-in; +} + +.chatReactions:empty { + display: none; } .chatMsg .content audio, @@ -1823,6 +1843,22 @@ button:disabled { margin-top: 8px; } +#mediaModalImg { + max-width: min(92vw, 1200px); + max-height: 78vh; + width: auto; + height: auto; + display: block; + margin: 0 auto; + border-radius: 14px; + background: rgba(0, 0, 0, 0.18); +} + +.editor.isDropActive { + outline: 2px dashed rgba(255, 62, 165, 0.7); + outline-offset: 6px; +} + .chatMsg .meta { font-size: 11px; color: var(--muted);