// static/js/08-settings.js "use strict"; window.Glow = window.Glow || {}; Glow.state = Glow.state || {}; Glow.state.settings = Glow.state.settings || null; Glow.applyThemeMode = mode => { const normalized = mode === "light" ? "light" : "dark"; document.body.classList.toggle("theme-light", normalized === "light"); localStorage.setItem("glow_theme_mode", normalized); }; Glow.applyCustomCss = cssText => { let style = document.getElementById("glowCustomCss"); if (!style) { style = document.createElement("style"); style.id = "glowCustomCss"; document.head.append(style); } style.textContent = typeof cssText === "string" ? cssText : ""; }; Glow.cloneSettingsState = source => { const value = source && typeof source === "object" ? source : {}; return { accent_color: Glow.normalizeAccentColor(value.accent_color || Glow.state.accentColor) || Glow.DEFAULT_ACCENT_COLOR, name_color: Glow.normalizeNameColor(value.name_color) || null, custom_css: typeof value.custom_css === "string" ? value.custom_css : "", theme_mode: value.theme_mode === "light" ? "light" : "dark", profile_background_color: Glow.normalizeAccentColor(value.profile_background_color) || "#121214", profile_banner_color: Glow.normalizeAccentColor(value.profile_banner_color) || "#3a3140", profile_visibility: ["public", "members", "private"].includes( String(value.profile_visibility || "").toLowerCase() ) ? String(value.profile_visibility).toLowerCase() : "public", last_active_visibility: ["everyone", "members", "nobody"].includes( String(value.last_active_visibility || "").toLowerCase() ) ? String(value.last_active_visibility).toLowerCase() : "everyone", presence_mode: ["online", "silent", "focus", "dnd"].includes( String(value.presence_mode || "").toLowerCase() ) ? String(value.presence_mode).toLowerCase() : "online" }; }; Glow.initializeSettingsDraft = settings => { const saved = Glow.cloneSettingsState(settings); Glow.state.settings = saved; Glow.state.settingsSaved = saved; Glow.state.settingsDraft = Glow.cloneSettingsState(saved); Glow.clearSettingsDirty?.(); }; Glow.previewSettingsDraft = () => { const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({}); Glow.applyAccentColor(draft.accent_color); Glow.applyThemeMode(draft.theme_mode); Glow.applyCustomCss(draft.custom_css || ""); }; Glow.updateSettingsDraft = patch => { const current = Glow.state.settingsDraft || Glow.cloneSettingsState({}); Glow.state.settingsDraft = { ...current, ...patch }; Glow.previewSettingsDraft(); Glow.markSettingsDirty?.(); }; Glow.syncSettingsSavePanel = () => { const panel = Glow.els.settingsSavePanel; if (!panel) return; const shouldShow = Glow.state.activeView === "settings" && Glow.isSettingsDirty?.(); panel.classList.toggle("hidden", !shouldShow); if (Glow.els.settingsUnsavedIndicator) { Glow.els.settingsUnsavedIndicator.textContent = shouldShow ? "Unsaved changes" : ""; } }; Glow.saveSettingsDraft = async () => { const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({}); const payload = { accent_color: Glow.normalizeAccentColor(draft.accent_color) || Glow.DEFAULT_ACCENT_COLOR, name_color: Glow.normalizeNameColor(draft.name_color) || null, custom_css: draft.custom_css || "", theme_mode: draft.theme_mode === "light" ? "light" : "dark", profile_background_color: Glow.normalizeAccentColor(draft.profile_background_color) || null, profile_banner_color: Glow.normalizeAccentColor(draft.profile_banner_color) || null, profile_visibility: ["public", "members", "private"].includes(draft.profile_visibility) ? draft.profile_visibility : "public", last_active_visibility: ["everyone", "members", "nobody"].includes(draft.last_active_visibility) ? draft.last_active_visibility : "everyone", presence_mode: ["online", "silent", "focus", "dnd"].includes(draft.presence_mode) ? draft.presence_mode : "online" }; const data = await Glow.apiUpdateMySettings(payload); const merged = { ...Glow.cloneSettingsState(draft), ...(data.settings || {}) }; Glow.state.settings = merged; Glow.state.settingsSaved = Glow.cloneSettingsState(merged); Glow.state.settingsDraft = Glow.cloneSettingsState(merged); Glow.previewSettingsDraft(); Glow.clearSettingsDirty?.(); if (Glow.state.currentUser) { Glow.state.currentUser.presence_mode = merged.presence_mode || Glow.state.currentUser.presence_mode || "online"; } Glow.showToast("Settings saved."); Glow.renderSettingsView(); }; Glow.discardSettingsDraft = () => { const saved = Glow.state.settingsSaved || Glow.cloneSettingsState({}); Glow.state.settingsDraft = Glow.cloneSettingsState(saved); Glow.previewSettingsDraft(); Glow.clearSettingsDirty?.(); if (Glow.state.activeView === "settings") { Glow.renderSettingsView(); } }; Glow.loadMySettings = async () => { try { const data = await Glow.apiGetMySettings(); const base = Glow.cloneSettingsState(data.settings || {}); Glow.initializeSettingsDraft(base); Glow.previewSettingsDraft(); } catch (_) { const fallback = Glow.cloneSettingsState({ theme_mode: localStorage.getItem("glow_theme_mode") || "dark", accent_color: Glow.state.accentColor, profile_visibility: "public", last_active_visibility: "everyone", presence_mode: "online" }); Glow.initializeSettingsDraft(fallback); Glow.previewSettingsDraft(); } }; Glow.renderHeaderMenu = () => { const list = Glow.els.headerMenuList; if (!list) return; list.replaceChildren(); const addItem = ({ label, hint = "", onClick, hidden = false }) => { if (hidden) return; const button = document.createElement("button"); button.className = "header-menu-item"; button.type = "button"; const left = document.createElement("span"); left.textContent = label; const right = document.createElement("small"); right.textContent = hint; button.append(left, right); button.addEventListener("click", () => { Glow.closeHeaderMenu?.(); onClick?.(); }); list.append(button); }; addItem({ label: "My profile", hint: Glow.state.currentUser ? `@${Glow.state.currentUser.username}` : "", onClick: () => Glow.openMyProfile?.(), hidden: !Glow.state.currentUser }); addItem({ label: "Settings", hint: "Theme, CSS, profile", onClick: () => Glow.openSettings?.() }); addItem({ label: "Direct messages", hint: Glow.state.currentUser ? "Private chat" : "", onClick: async () => { Glow.state.activeView = "dms"; Glow.updateViewUI(); await Glow.loadDms?.(); Glow.schedulePoll?.(); }, hidden: !Glow.state.currentUser }); addItem({ label: "Create team", hint: "Up to 3 max", onClick: () => Glow.openCreateTeamDialog?.(), hidden: !Glow.state.currentUser }); addItem({ label: "Edit current team", hint: Glow.state.activeTeam?.can_manage ? "Manage" : "", onClick: () => Glow.openEditTeamDialog?.(Glow.state.activeTeam), hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage }); addItem({ label: "Add channel", hint: Glow.state.activeTeam?.name || "", onClick: () => Glow.openCreateChannelDialog?.(Glow.state.activeTeam), hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage }); addItem({ label: "Edit current channel", hint: Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "", onClick: () => Glow.openEditChannelDialog?.( Glow.state.activeTeam, Glow.state.activeChannel || Glow.state.activeRoom ), hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage || !(Glow.state.activeChannel || Glow.state.activeRoom) || Glow.state.activeView === "dm" }); addItem({ label: "Manage team members", hint: Glow.state.activeTeam?.can_manage ? "Members" : "", onClick: () => Glow.manageTeamMembers?.(Glow.state.activeTeam), hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage }); addItem({ label: "Create team invite", hint: Glow.state.activeTeam?.can_manage ? "Share" : "", onClick: () => Glow.createTeamInviteLink?.(Glow.state.activeTeam), hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage }); addItem({ label: "Delete current channel", hint: Glow.state.activeChannel?.name || "", onClick: () => Glow.deleteCurrentChannel?.(Glow.state.activeTeam), hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage || !Glow.state.activeChannel || Boolean(Glow.state.activeChannel?.legacy_room_id) }); }; Glow.openHeaderMenu = () => { Glow.renderHeaderMenu(); Glow.els.headerMenuOverlay?.classList.remove("hidden"); }; Glow.closeHeaderMenu = () => { Glow.els.headerMenuOverlay?.classList.add("hidden"); }; Glow.toggleHeaderMenu = () => { if (Glow.els.headerMenuOverlay?.classList.contains("hidden")) { Glow.openHeaderMenu(); } else { Glow.closeHeaderMenu(); } }; Glow.openSettings = async () => { Glow.state.activeView = "settings"; Glow.updateViewUI(); if (!Glow.state.settingsSaved && Glow.state.currentUser) { await Glow.loadMySettings(); } else if (!Glow.state.settingsDraft) { Glow.initializeSettingsDraft( Glow.state.settingsSaved || Glow.cloneSettingsState({}) ); Glow.previewSettingsDraft(); } Glow.renderSettingsView(); Glow.syncSettingsSavePanel(); }; Glow.renderNameColorPreview = value => { const wrap = document.createElement("div"); wrap.className = "settings-row"; const label = document.createElement("div"); label.className = "header-subtitle"; label.textContent = "Preview"; const preview = document.createElement("div"); preview.className = "name-color-preview"; preview.textContent = Glow.state.currentUser?.username || "username"; preview.style.color = Glow.normalizeNameColor(value) || Glow.roleDefaultNameColor(Glow.state.currentUser?.role); wrap.append(label, preview); return wrap; }; Glow.renderChoicePills = ({ label, value, options, onChange }) => { const wrap = document.createElement("div"); wrap.className = "settings-row"; const title = document.createElement("div"); title.className = "dialog-label"; title.textContent = label; const row = document.createElement("div"); row.className = "settings-theme-row"; for (const option of options) { const button = document.createElement("button"); button.className = `theme-pill${value === option.value ? " active" : ""}`; button.type = "button"; button.textContent = option.label; button.addEventListener("click", () => onChange(option.value)); row.append(button); } wrap.append(title, row); return wrap; }; Glow.renderSettingsView = () => { const box = Glow.els.profileBox; box.replaceChildren(); const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({}); const card = document.createElement("div"); card.className = "settings-card"; const identity = document.createElement("section"); identity.className = "settings-section"; const iTitle = document.createElement("h2"); iTitle.textContent = "Identity"; const iText = document.createElement("p"); iText.textContent = "Adjust how your name and profile presence appear across the app."; const nameColorWrap = document.createElement("div"); nameColorWrap.className = "settings-row"; const nameColorLabel = document.createElement("label"); nameColorLabel.className = "dialog-label"; nameColorLabel.textContent = "Username color"; const nameColorInput = document.createElement("input"); nameColorInput.type = "color"; nameColorInput.className = "accent-color-input"; nameColorInput.value = Glow.normalizeNameColor(draft.name_color) || Glow.roleDefaultNameColor(Glow.state.currentUser?.role); const nameColorText = document.createElement("input"); nameColorText.className = "field accent-hex-input"; nameColorText.type = "text"; nameColorText.maxLength = 7; nameColorText.value = Glow.normalizeNameColor(draft.name_color) || ""; nameColorText.placeholder = "#7dd3fc"; const nameRow = document.createElement("div"); nameRow.className = "accent-picker-row"; const previewSwatch = document.createElement("span"); previewSwatch.className = "accent-preview"; previewSwatch.style.background = Glow.normalizeNameColor(draft.name_color) || Glow.roleDefaultNameColor(Glow.state.currentUser?.role); nameColorInput.addEventListener("input", () => { nameColorText.value = nameColorInput.value; Glow.updateSettingsDraft({ name_color: nameColorInput.value }); }); nameColorText.addEventListener("input", () => { const normalized = Glow.normalizeNameColor(nameColorText.value.trim()); Glow.updateSettingsDraft({ name_color: normalized || null }); }); const resetNameColor = document.createElement("button"); resetNameColor.className = "button secondary"; resetNameColor.type = "button"; resetNameColor.textContent = "Use role default"; resetNameColor.addEventListener("click", () => { Glow.updateSettingsDraft({ name_color: null }); Glow.renderSettingsView(); }); nameRow.append(nameColorInput, nameColorText, previewSwatch); nameColorLabel.append(nameRow); nameColorWrap.append( nameColorLabel, Glow.renderNameColorPreview(draft.name_color), resetNameColor ); const profileVisibility = Glow.renderChoicePills({ label: "Profile visibility", value: draft.profile_visibility || "public", options: [ { value: "public", label: "Public" }, { value: "members", label: "Members" }, { value: "private", label: "Private" } ], onChange: value => { Glow.updateSettingsDraft({ profile_visibility: value }); Glow.renderSettingsView(); } }); const lastActiveVisibility = Glow.renderChoicePills({ label: "Last active visibility", value: draft.last_active_visibility || "everyone", options: [ { value: "everyone", label: "Everyone" }, { value: "members", label: "Members" }, { value: "nobody", label: "Nobody" } ], onChange: value => { Glow.updateSettingsDraft({ last_active_visibility: value }); Glow.renderSettingsView(); } }); const presenceMode = Glow.renderChoicePills({ label: "Presence mode", value: draft.presence_mode || "online", options: [ { value: "online", label: "Online" }, { value: "silent", label: "Silent" }, { value: "focus", label: "Focus" }, { value: "dnd", label: "Dnd" } ], onChange: value => { Glow.updateSettingsDraft({ presence_mode: value }); Glow.renderSettingsView(); } }); identity.append( iTitle, iText, nameColorWrap, profileVisibility, lastActiveVisibility, presenceMode ); const appearance = document.createElement("section"); appearance.className = "settings-section"; const aTitle = document.createElement("h2"); aTitle.textContent = "Appearance"; const aText = document.createElement("p"); aText.textContent = "Set theme and accent color."; const themeRow = document.createElement("div"); themeRow.className = "settings-theme-row"; const darkButton = document.createElement("button"); darkButton.className = `theme-pill${draft.theme_mode !== "light" ? " active" : ""}`; darkButton.type = "button"; darkButton.textContent = "Dark"; const lightButton = document.createElement("button"); lightButton.className = `theme-pill${draft.theme_mode === "light" ? " active" : ""}`; lightButton.type = "button"; lightButton.textContent = "Light"; darkButton.addEventListener("click", () => { Glow.updateSettingsDraft({ theme_mode: "dark" }); Glow.renderSettingsView(); }); lightButton.addEventListener("click", () => { Glow.updateSettingsDraft({ theme_mode: "light" }); Glow.renderSettingsView(); }); themeRow.append(darkButton, lightButton); const accentWrap = document.createElement("div"); accentWrap.className = "settings-row"; const accentLabel = document.createElement("label"); accentLabel.className = "dialog-label"; accentLabel.textContent = "Accent color"; const accentInput = document.createElement("input"); accentInput.type = "color"; accentInput.className = "accent-color-input"; accentInput.value = Glow.normalizeAccentColor(draft.accent_color) || Glow.DEFAULT_ACCENT_COLOR; accentInput.addEventListener("input", () => { Glow.updateSettingsDraft({ accent_color: accentInput.value }); }); accentLabel.append(accentInput); accentWrap.append(accentLabel); appearance.append(aTitle, aText, themeRow, accentWrap); const profileVisuals = document.createElement("section"); profileVisuals.className = "settings-section"; const pvTitle = document.createElement("h2"); pvTitle.textContent = "Profile visuals"; const pvText = document.createElement("p"); pvText.textContent = "Adjust background and banner colors used in your profile view."; const backgroundWrap = document.createElement("div"); backgroundWrap.className = "settings-row"; const backgroundLabel = document.createElement("label"); backgroundLabel.className = "dialog-label"; backgroundLabel.textContent = "Profile background color"; const backgroundInput = document.createElement("input"); backgroundInput.type = "color"; backgroundInput.className = "accent-color-input"; backgroundInput.value = Glow.normalizeAccentColor(draft.profile_background_color) || "#121214"; backgroundInput.addEventListener("input", () => { Glow.updateSettingsDraft({ profile_background_color: backgroundInput.value }); }); backgroundLabel.append(backgroundInput); backgroundWrap.append(backgroundLabel); const bannerWrap = document.createElement("div"); bannerWrap.className = "settings-row"; const bannerLabel = document.createElement("label"); bannerLabel.className = "dialog-label"; bannerLabel.textContent = "Profile banner color"; const bannerInput = document.createElement("input"); bannerInput.type = "color"; bannerInput.className = "accent-color-input"; bannerInput.value = Glow.normalizeAccentColor(draft.profile_banner_color) || "#3a3140"; bannerInput.addEventListener("input", () => { Glow.updateSettingsDraft({ profile_banner_color: bannerInput.value }); }); bannerLabel.append(bannerInput); bannerWrap.append(bannerLabel); profileVisuals.append(pvTitle, pvText, backgroundWrap, bannerWrap); const cssSection = document.createElement("section"); cssSection.className = "settings-section"; const cTitle = document.createElement("h2"); cTitle.textContent = "Custom CSS"; const cText = document.createElement("p"); cText.textContent = "Optional CSS applied locally for preview and saved to your account when supported."; const cssInput = document.createElement("textarea"); cssInput.className = "field settings-custom-css"; cssInput.placeholder = "/* Custom CSS */"; cssInput.value = draft.custom_css || ""; cssInput.addEventListener("input", () => { Glow.updateSettingsDraft({ custom_css: cssInput.value }); }); cssSection.append(cTitle, cText, cssInput); const accountSection = document.createElement("section"); accountSection.className = "settings-section"; const accountTitle = document.createElement("h2"); accountTitle.textContent = "Account"; const accountText = document.createElement("p"); accountText.textContent = "Open your profile editor or reset visual customizations."; const actions = document.createElement("div"); actions.className = "settings-actions"; const editProfile = document.createElement("button"); editProfile.className = "button"; editProfile.type = "button"; editProfile.textContent = "Edit profile"; editProfile.addEventListener("click", () => { Glow.openMyProfile?.(true); }); const resetAccent = document.createElement("button"); resetAccent.className = "button secondary"; resetAccent.type = "button"; resetAccent.textContent = "Reset accent"; resetAccent.addEventListener("click", () => { Glow.updateSettingsDraft({ accent_color: Glow.DEFAULT_ACCENT_COLOR }); Glow.renderSettingsView(); }); const discard = document.createElement("button"); discard.className = "button secondary"; discard.type = "button"; discard.textContent = "Discard changes"; discard.addEventListener("click", () => { Glow.discardSettingsDraft(); }); actions.append(editProfile, resetAccent, discard); accountSection.append(accountTitle, accountText, actions); card.append(identity, appearance, profileVisuals, cssSection, accountSection); box.append(card); Glow.syncSettingsSavePanel(); }; Glow.els.settingsSaveButton?.addEventListener("click", async () => { try { await Glow.saveSettingsDraft(); } catch (error) { Glow.showToast(error.message || "Could not save settings.", "error"); } }); Glow.els.settingsCloseButton?.addEventListener("click", async () => { if (!Glow.isSettingsDirty?.()) { await Glow.openFeed?.(); return; } const values = await Glow.openDialog({ title: "Leave settings?", body: "You have unsaved changes.", confirmText: "Discard changes", cancelText: "Stay", danger: true }); if (!values) return; Glow.discardSettingsDraft(); await Glow.openFeed?.(); });