// static/js/09-profile.js "use strict"; window.Glow = window.Glow || {}; Glow.escapeHtml = value => String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); Glow.renderProfileBody = profile => { const wrap = document.createElement("div"); wrap.className = "profile-rendered"; const content = String(profile?.bio_html || "").trim(); if (!content) { const empty = document.createElement("div"); empty.className = "profile-empty"; empty.textContent = "No profile bio yet."; return empty; } wrap.innerHTML = content; return wrap; }; Glow.openProfile = async username => { Glow.state.activeView = "profile"; Glow.state.activeProfile = null; Glow.updateViewUI(); const box = Glow.els.profileBox; box.setAttribute("aria-busy", "true"); box.replaceChildren(); try { const data = await Glow.apiGetPublicProfile(username); Glow.state.activeProfile = data.profile; Glow.renderProfileView(false); } catch (error) { box.replaceChildren(); const state = document.createElement("div"); state.className = "empty-state"; const card = document.createElement("div"); card.className = "empty-card"; card.textContent = error.message || "Could not load profile."; state.append(card); box.append(state); Glow.showToast(error.message || "Could not load profile.", "error"); } finally { box.setAttribute("aria-busy", "false"); } }; Glow.openMyProfile = async (edit = false) => { if (!Glow.state.currentUser) { Glow.showToast("Sign in to view your profile.", "error"); return; } Glow.state.activeView = "profile"; Glow.state.activeProfile = null; Glow.updateViewUI(); const box = Glow.els.profileBox; box.setAttribute("aria-busy", "true"); box.replaceChildren(); try { const data = await Glow.apiGetMyProfile(); Glow.state.activeProfile = data.profile; Glow.renderProfileView(edit); } catch (error) { Glow.showToast(error.message || "Could not load your profile.", "error"); } finally { box.setAttribute("aria-busy", "false"); } }; Glow.uploadBannerForProfile = async file => { const result = await Glow.uploadAttachment(file); return result?.attachment || null; }; Glow.renderProfileView = (editing = false) => { const box = Glow.els.profileBox; const profile = Glow.state.activeProfile; box.replaceChildren(); if (!profile) { const state = document.createElement("div"); state.className = "empty-state"; const card = document.createElement("div"); card.className = "empty-card"; card.textContent = "Profile not found."; state.append(card); box.append(state); return; } box.style.background = profile.profile_background_color || "transparent"; const card = document.createElement("div"); card.className = "profile-card"; const banner = document.createElement("div"); banner.className = "profile-banner"; if (profile.profile_banner_color) { banner.style.background = `linear-gradient(135deg, ${profile.profile_banner_color}, transparent 72%), ${profile.profile_banner_color}`; } if (profile.banner_image_url) { banner.style.backgroundImage = `linear-gradient(135deg, rgba(0,0,0,0.18), rgba(0,0,0,0.04)), url("${profile.banner_image_url}")`; banner.style.backgroundSize = "cover"; banner.style.backgroundPosition = "center"; } const content = document.createElement("div"); content.className = "profile-content"; const header = document.createElement("div"); header.className = "profile-header"; const avatar = document.createElement("div"); avatar.className = "profile-avatar"; avatar.textContent = (profile.display_name || profile.username || "?") .slice(0, 1) .toUpperCase(); const actions = document.createElement("div"); actions.className = "profile-actions"; if (profile.is_self) { const editButton = document.createElement("button"); editButton.className = "button"; editButton.type = "button"; editButton.textContent = editing ? "View profile" : "Edit profile"; editButton.addEventListener("click", () => { Glow.renderProfileView(!editing); }); const settingsButton = document.createElement("button"); settingsButton.className = "button secondary"; settingsButton.type = "button"; settingsButton.textContent = "Settings"; settingsButton.addEventListener("click", () => { Glow.openSettings?.(); }); actions.append(editButton, settingsButton); } header.append(avatar, actions); const name = document.createElement("div"); name.className = "profile-name"; name.textContent = profile.display_name || profile.username; name.style.color = Glow.resolveUserNameColor({ role: profile.role, name_color: profile.name_color }); const handle = document.createElement("div"); handle.className = "profile-handle"; handle.textContent = `@${profile.username}`; handle.style.color = Glow.resolveUserNameColor({ role: profile.role, name_color: profile.name_color }); const badges = document.createElement("div"); badges.className = "profile-badges"; const roleBadge = document.createElement("span"); roleBadge.className = "profile-badge"; roleBadge.textContent = profile.role; const statusBadge = document.createElement("span"); statusBadge.className = "profile-badge"; statusBadge.textContent = profile.status; const presenceBadge = document.createElement("span"); presenceBadge.className = "profile-badge"; presenceBadge.textContent = profile.presence_mode || "online"; badges.append(roleBadge, statusBadge, presenceBadge); const grid = document.createElement("div"); grid.className = "profile-grid"; if (editing && profile.is_self) { grid.append(Glow.buildProfileEditor(profile)); } else { const bioSection = document.createElement("section"); bioSection.className = "profile-section"; const bioTitle = document.createElement("h3"); bioTitle.textContent = "Bio"; bioSection.append(bioTitle, Glow.renderProfileBody(profile)); const metaSection = document.createElement("section"); metaSection.className = "profile-section"; const metaTitle = document.createElement("h3"); metaTitle.textContent = "Profile"; const metaList = document.createElement("dl"); metaList.className = "profile-meta-list"; const addMeta = (label, value) => { const dt = document.createElement("dt"); dt.textContent = label; const dd = document.createElement("dd"); dd.textContent = value || "—"; metaList.append(dt, dd); }; addMeta("Display name", profile.display_name || profile.username); addMeta("Status message", profile.status_message || ""); addMeta("Presence mode", profile.presence_mode || "online"); addMeta("Website", profile.website || ""); addMeta("Location", profile.location || ""); addMeta( "Joined", profile.created_at ? new Date(profile.created_at).toLocaleDateString() : "" ); addMeta( "Last active", profile.last_active_at ? new Date(profile.last_active_at).toLocaleString() : "" ); addMeta("Bio mode", profile.bio_mode || "txt"); addMeta("Name color", profile.name_color || "role default"); metaSection.append(metaTitle, metaList); grid.append(bioSection, metaSection); } content.append(header, name, handle); if (profile.status_message) { const statusMessage = document.createElement("div"); statusMessage.className = "profile-status-message"; statusMessage.textContent = profile.status_message; content.append(statusMessage); } content.append(badges, grid); card.append(banner, content); box.append(card); }; Glow.buildProfileEditor = profile => { const section = document.createElement("section"); section.className = "profile-section"; const title = document.createElement("h3"); title.textContent = "Edit profile"; const editor = document.createElement("div"); editor.className = "profile-editor"; const displayName = document.createElement("input"); displayName.className = "field"; displayName.type = "text"; displayName.maxLength = 80; displayName.placeholder = "Display name"; displayName.value = profile.display_name || ""; const statusMessage = document.createElement("input"); statusMessage.className = "field"; statusMessage.type = "text"; statusMessage.maxLength = 160; statusMessage.placeholder = "Status message"; statusMessage.value = profile.status_message || ""; const website = document.createElement("input"); website.className = "field"; website.type = "url"; website.maxLength = 300; website.placeholder = "Website URL"; website.value = profile.website || ""; const location = document.createElement("input"); location.className = "field"; location.type = "text"; location.maxLength = 120; location.placeholder = "Location"; location.value = profile.location || ""; const bannerInput = document.createElement("input"); bannerInput.className = "field"; bannerInput.type = "file"; bannerInput.accept = ".png,.jpg,.jpeg,.gif,.webp"; const modeRow = document.createElement("div"); modeRow.className = "profile-mode-row"; let selectedMode = profile.bio_mode || "txt"; const body = document.createElement("textarea"); body.className = "field"; body.maxLength = 5000; body.placeholder = "Tell people about yourself"; body.value = profile.bio || ""; for (const mode of ["txt", "md", "html"]) { const button = document.createElement("button"); button.className = `mode-pill${selectedMode === mode ? " active" : ""}`; button.type = "button"; button.textContent = mode.toUpperCase(); button.addEventListener("click", () => { selectedMode = mode; for (const el of modeRow.querySelectorAll(".mode-pill")) { el.classList.toggle("active", el.textContent.toLowerCase() === mode); } }); modeRow.append(button); } const help = document.createElement("p"); help.className = "header-subtitle"; help.textContent = "HTML is sanitized on save/display. Scripts, event handlers, and unsafe URLs are removed."; const actions = document.createElement("div"); actions.className = "settings-actions"; const save = document.createElement("button"); save.className = "button"; save.type = "button"; save.textContent = "Save profile"; save.addEventListener("click", async () => { try { let bannerAttachmentId = profile.banner_attachment_id || null; const file = bannerInput.files?.[0]; if (file) { const uploaded = await Glow.uploadBannerForProfile(file); bannerAttachmentId = uploaded?.id || bannerAttachmentId; } const data = await Glow.apiUpdateMyProfile({ display_name: displayName.value, status_message: statusMessage.value, website: website.value, location: location.value, bio: body.value, bio_mode: selectedMode, banner_attachment_id: bannerAttachmentId }); Glow.state.activeProfile = { ...data.profile, name_color: Glow.state.settings?.name_color || profile.name_color || null, profile_background_color: Glow.state.settings?.profile_background_color || null, profile_banner_color: Glow.state.settings?.profile_banner_color || null }; Glow.showToast("Profile updated."); Glow.renderProfileView(false); } catch (error) { Glow.showToast(error.message || "Could not update profile.", "error"); } }); const preview = document.createElement("button"); preview.className = "button secondary"; preview.type = "button"; preview.textContent = "Preview"; preview.addEventListener("click", async () => { try { let bannerAttachmentId = profile.banner_attachment_id || null; const file = bannerInput.files?.[0]; if (file) { const uploaded = await Glow.uploadBannerForProfile(file); bannerAttachmentId = uploaded?.id || bannerAttachmentId; } const data = await Glow.apiUpdateMyProfile({ display_name: displayName.value, status_message: statusMessage.value, website: website.value, location: location.value, bio: body.value, bio_mode: selectedMode, banner_attachment_id: bannerAttachmentId }); Glow.state.activeProfile = { ...data.profile, name_color: Glow.state.settings?.name_color || profile.name_color || null, profile_background_color: Glow.state.settings?.profile_background_color || null, profile_banner_color: Glow.state.settings?.profile_banner_color || null }; Glow.renderProfileView(false); } catch (error) { Glow.showToast(error.message || "Could not preview profile.", "error"); } }); actions.append(save, preview); editor.append( displayName, statusMessage, website, location, bannerInput, modeRow, help, body, actions ); section.append(title, editor); return section; };