glo/static/js/09-profile.js

633 lines
19 KiB
JavaScript

// static/js/09-profile.js
"use strict";
window.Glow = window.Glow || {};
Glow.escapeHtml = value =>
String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
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.profileCanShowPresenceBadge = profile => {
if (!profile) return false;
if (profile.is_self) return true;
if (typeof profile.show_presence_badge === "boolean") {
return profile.show_presence_badge;
}
return Glow.state.profilePresenceBadgeVisible !== false;
};
Glow.profileWebsiteMeta = website => {
return Glow.websiteBrandMeta?.(website) || {
host: "",
brand: "web",
label: "website",
iconText: "â—‹"
};
};
Glow.profileOpenMediaPicker = kind => {
if (kind === "avatar") {
Glow.els.avatarUploadInput?.click();
return;
}
if (kind === "banner") {
Glow.els.bannerUploadInput?.click();
}
};
Glow.profileResetCropState = () => {
const crop = Glow.state.imageCrop;
if (crop.previewUrl) {
try {
URL.revokeObjectURL(crop.previewUrl);
} catch (_) {}
}
Glow.state.imageCrop = {
open: false,
kind: "",
file: null,
previewUrl: "",
zoom: 1,
autoCrop: false
};
if (Glow.els.imageCropOverlay) {
Glow.els.imageCropOverlay.classList.add("hidden");
}
if (Glow.els.imageCropPreview) {
Glow.els.imageCropPreview.replaceChildren();
}
if (Glow.els.imageCropZoom) {
Glow.els.imageCropZoom.value = "1";
}
if (Glow.els.imageCropAuto) {
Glow.els.imageCropAuto.checked = false;
}
};
Glow.profileOpenCropDialog = (kind, file) => {
if (!file || !kind) return;
Glow.profileResetCropState();
const previewUrl = URL.createObjectURL(file);
Glow.state.imageCrop = {
open: true,
kind,
file,
previewUrl,
zoom: 1,
autoCrop: false
};
if (Glow.els.imageCropPreview) {
const image = document.createElement("img");
image.src = previewUrl;
image.alt = `${kind} preview`;
image.className = "crop-preview-image";
Glow.els.imageCropPreview.replaceChildren(image);
}
Glow.els.imageCropOverlay?.classList.remove("hidden");
};
Glow.profileApplyCropSelection = () => {
const crop = Glow.state.imageCrop;
if (!crop?.file || !crop?.kind) {
Glow.profileResetCropState();
return;
}
if (crop.kind === "avatar") {
Glow.state.profileMediaDraft.avatarFile = crop.file;
} else if (crop.kind === "banner") {
Glow.state.profileMediaDraft.bannerFile = crop.file;
}
Glow.showToast(
crop.kind === "avatar"
? "Avatar image selected. Save profile changes to keep it."
: "Banner image selected. Save profile changes to keep it."
);
Glow.profileResetCropState();
};
Glow.profileBuildWebsiteBadge = website => {
if (!website) return null;
const meta = Glow.profileWebsiteMeta(website);
const link = document.createElement("a");
link.className = `profile-website-badge brand-${meta.brand}`;
link.href = website;
link.target = "_blank";
link.rel = "noopener noreferrer";
const icon = document.createElement("span");
icon.className = "profile-website-badge-icon";
icon.textContent = meta.iconText;
const text = document.createElement("span");
text.className = "profile-website-badge-text";
text.textContent = meta.label;
link.append(icon, text);
return link;
};
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(profile.is_self ? "button" : "div");
banner.className = "profile-banner";
if (profile.is_self) {
banner.type = "button";
banner.classList.add("profile-banner-button");
banner.title = "Choose banner image";
banner.addEventListener("click", () => {
Glow.profileOpenMediaPicker("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";
}
if (profile.is_self) {
const bannerHint = document.createElement("span");
bannerHint.className = "profile-banner-hint";
bannerHint.textContent = "Change banner";
banner.append(bannerHint);
}
const content = document.createElement("div");
content.className = "profile-content";
const header = document.createElement("div");
header.className = "profile-header";
const avatar = document.createElement(profile.is_self ? "button" : "div");
avatar.className = "profile-avatar";
avatar.textContent = (profile.display_name || profile.username || "?")
.slice(0, 1)
.toUpperCase();
if (profile.is_self) {
avatar.type = "button";
avatar.classList.add("profile-avatar-button");
avatar.title = "Choose avatar image";
avatar.addEventListener("click", () => {
Glow.profileOpenMediaPicker("avatar");
});
}
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 role role-${String(profile.role || "user").toLowerCase()}`;
roleBadge.textContent = profile.role;
const statusBadge = document.createElement("span");
statusBadge.className = `profile-badge status status-${String(profile.status || "active").toLowerCase()}`;
statusBadge.textContent = profile.status;
badges.append(roleBadge, statusBadge);
if (Glow.profileCanShowPresenceBadge(profile)) {
const presenceBadge = document.createElement("span");
presenceBadge.className =
`profile-badge presence presence-${String(profile.presence_mode || "online").toLowerCase()}`;
presenceBadge.textContent = profile.presence_mode || "online";
badges.append(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) => {
if (!value) return;
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
dd.textContent = value;
metaList.append(dt, dd);
};
addMeta("Joined", profile.created_at
? new Date(profile.created_at).toLocaleDateString()
: "");
addMeta("Location", profile.location || "");
if (profile.last_active_at) {
addMeta("Last active", new Date(profile.last_active_at).toLocaleString());
}
const websiteBadge = Glow.profileBuildWebsiteBadge(profile.website || "");
if (websiteBadge) {
const dt = document.createElement("dt");
dt.textContent = "Website";
const dd = document.createElement("dd");
dd.append(websiteBadge);
metaList.append(dt, dd);
}
if (!metaList.children.length) {
const empty = document.createElement("div");
empty.className = "profile-empty";
empty.textContent = "No public profile details yet.";
metaSection.append(metaTitle, empty);
} else {
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 uploadInfo = document.createElement("div");
uploadInfo.className = "profile-upload-section";
const uploadTitle = document.createElement("div");
uploadTitle.className = "profile-upload-title";
uploadTitle.textContent = "Profile images";
const uploadText = document.createElement("p");
uploadText.className = "header-subtitle";
uploadText.textContent =
"Choose new avatar or banner images. A preview/crop step opens before the file is staged.";
const uploadActions = document.createElement("div");
uploadActions.className = "settings-actions";
const avatarButton = document.createElement("button");
avatarButton.className = "button secondary";
avatarButton.type = "button";
avatarButton.textContent = "Choose avatar";
avatarButton.addEventListener("click", () => {
Glow.profileOpenMediaPicker("avatar");
});
const bannerButton = document.createElement("button");
bannerButton.className = "button secondary";
bannerButton.type = "button";
bannerButton.textContent = "Choose banner";
bannerButton.addEventListener("click", () => {
Glow.profileOpenMediaPicker("banner");
});
uploadActions.append(avatarButton, bannerButton);
uploadInfo.append(uploadTitle, uploadText, uploadActions);
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 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 changes";
save.addEventListener("click", async () => {
try {
let bannerAttachmentId = profile.banner_attachment_id || null;
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
if (bannerFile) {
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
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.profileMediaDraft.avatarFile = null;
Glow.state.profileMediaDraft.bannerFile = null;
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 apply = document.createElement("button");
apply.className = "button secondary";
apply.type = "button";
apply.textContent = "Apply changes";
apply.addEventListener("click", async () => {
try {
let bannerAttachmentId = profile.banner_attachment_id || null;
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
if (bannerFile) {
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
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 apply profile changes.", "error");
}
});
actions.append(save, apply);
editor.append(
uploadInfo,
displayName,
statusMessage,
website,
location,
modeRow,
help,
body,
actions
);
section.append(title, editor);
return section;
};
Glow.els.avatarUploadInput?.addEventListener("change", () => {
const file = Glow.els.avatarUploadInput.files?.[0];
if (!file) return;
Glow.profileOpenCropDialog("avatar", file);
Glow.els.avatarUploadInput.value = "";
});
Glow.els.bannerUploadInput?.addEventListener("change", () => {
const file = Glow.els.bannerUploadInput.files?.[0];
if (!file) return;
Glow.profileOpenCropDialog("banner", file);
Glow.els.bannerUploadInput.value = "";
});
Glow.els.imageCropCancel?.addEventListener("click", () => {
Glow.profileResetCropState();
});
Glow.els.imageCropApply?.addEventListener("click", () => {
Glow.profileApplyCropSelection();
});
Glow.els.imageCropZoom?.addEventListener("input", () => {
Glow.state.imageCrop.zoom = Number(Glow.els.imageCropZoom.value || "1") || 1;
});
Glow.els.imageCropAuto?.addEventListener("change", () => {
Glow.state.imageCrop.autoCrop = Boolean(Glow.els.imageCropAuto.checked);
});