{% extends "root.html" %} {% block head %} <title>Settings - {{ config.name }}</title> {% endblock %} {% block body %} {{ macros::nav() }} <main class="flex flex-col gap-2"> {% if profile.id != user.id %} <div class="card w-full red flex gap-2 items-center"> {{ icon "skull" }} <b>Editing other user's settings! Please be careful.</b> </div> {% endif %} <div class="pillmenu"> <a data-tab-button="account" class="active" href="#/account"> {{ icon "smile" }} <span>{{ text "settings:tab.account" }}</span> </a> <a data-tab-button="profile" href="#/profile"> {{ icon "user-round" }} <span>{{ text "settings:tab.profile" }}</span> </a> <a data-tab-button="theme" href="#/theme"> {{ icon "paint-bucket" }} <span>{{ text "settings:tab.theme" }}</span> </a> <a data-tab-button="sessions" href="#/sessions"> {{ icon "cookie" }} <span>{{ text "settings:tab.sessions" }}</span> </a> </div> <div class="w-full flex flex-col gap-2" data-tab="account"> <div class="card tertiary flex flex-col gap-2" id="account_settings"> <div class="card-nest" ui_ident="change_password"> <div class="card small"> <b>{{ text "settings:label.change_password" }}</b> </div> <form class="card flex flex-col gap-2" onsubmit="change_password(event)" > <div class="flex flex-col gap-1"> <label for="current_password" >{{ text "settings:label.current_password" }}</label > <input type="password" name="current_password" id="current_password" placeholder="current_password" required minlength="6" autocomplete="off" /> </div> <div class="flex flex-col gap-1"> <label for="new_password" >{{ text "settings:label.new_password" }}</label > <input type="password" name="new_password" id="new_password" placeholder="new_password" required minlength="6" autocomplete="off" /> </div> <button class="primary"> {{ icon "check" }} <span>{{ text "general:action.save" }}</span> </button> </form> </div> <div class="card-nest" ui_ident="change_username"> <div class="card small"> <b>{{ text "settings:label.change_username" }}</b> </div> <form class="card flex flex-col gap-2" onsubmit="change_username(event)" > <div class="flex flex-col gap-1"> <label for="new_username" >{{ text "settings:label.new_username" }}</label > <input type="text" name="new_username" id="new_username" placeholder="new_username" required minlength="2" /> </div> <button class="primary"> {{ icon "check" }} <span>{{ text "general:action.save" }}</span> </button> </form> </div> <div class="card-nest" ui_ident="two_factor_authentication"> <div class="card small"> <b>{{ text "settings:label.two_factor_authentication" }}</b> </div> <div class="card flex flex-col gap-2"> {% if profile.totp|length == 0 %} <div id="totp_stuff" style="display: none"> <span >Scan this QR code in a TOTP authenticator app (like Google Authenticator): </span> <img id="totp_qr" style="max-width: 250px" /> <span>TOTP secret (do NOT share):</span> <pre id="totp_secret"></pre> <span >Recovery codes (STORE SAFELY, these can only be viewed once):</span > <pre id="totp_recovery_codes"></pre> </div> <button class="quaternary green" onclick="enable_totp(event)" > Enable TOTP 2FA </button> {% else %} <pre id="totp_recovery_codes" style="display: none"></pre> <div class="flex gap-2 flex-wrap"> <button class="quaternary red" onclick="refresh_totp_codes(event)" > Refresh recovery codes </button> <button class="quaternary red" onclick="disable_totp(event)" > Disable TOTP 2FA </button> </div> {% endif %} </div> </div> </div> <div class="card-nest" ui_ident="change_password"> <div class="card small flex items-center gap-2 red"> {{ icon "skull" }} <b>{{ text "settings:label.delete_account" }}</b> </div> <form class="card flex flex-col gap-2" onsubmit="delete_account(event)" > <div class="flex flex-col gap-1"> <label for="current_password" >{{ text "settings:label.current_password" }}</label > <input type="password" name="current_password" id="current_password" placeholder="current_password" required minlength="6" autocomplete="off" /> </div> <button class="primary"> {{ icon "trash" }} <span>{{ text "general:action.delete" }}</span> </button> </form> </div> <button onclick="save_settings()" id="save_button"> {{ icon "check" }} <span>{{ text "general:action.save" }}</span> </button> </div> <div class="w-full hidden flex flex-col gap-2" data-tab="profile"> <div class="card tertiary flex flex-col gap-2" id="profile_settings"> <div class="card-nest" ui_ident="change_avatar"> <div class="card small"> <b>{{ text "settings:label.change_avatar" }}</b> </div> <form class="card flex gap-2 flex-row flex-wrap items-center" method="post" enctype="multipart/form-data" onsubmit="upload_avatar(event)" > <div class="flex gap-2 flex-row flex-wrap items-center"> <input id="avatar_file" name="file" type="file" accept="image/png,image/jpeg,image/avif,image/webp" class="w-content" /> <button class="primary">{{ icon "check" }}</button> </div> <span class="fade" >Images must be less than 8 MB large. Animated images such as GIFs or APNGs will not work because of all images being formatted as AVIF.</span > </form> </div> <div class="card-nest" ui_ident="change_banner"> <div class="card small"> <b>{{ text "settings:label.change_banner" }}</b> </div> <form class="card flex flex-col gap-2" method="post" enctype="multipart/form-data" onsubmit="upload_banner(event)" > <div class="flex gap-2 flex-row flex-wrap items-center"> <input id="banner_file" name="file" type="file" accept="image/png,image/jpeg,image/avif,image/webp" class="w-content" /> <button class="primary">{{ icon "check" }}</button> </div> <span class="fade" >Use an image of 1100x350px for the best results.</span > </form> </div> </div> <button onclick="save_settings()" id="save_button"> {{ icon "check" }} <span>{{ text "general:action.save" }}</span> </button> </div> <div class="card w-full tertiary hidden flex flex-col gap-2" data-tab="sessions" > {% for token in profile.tokens %} <div class="card w-full flex justify-between flex-collapse gap-2"> <div class="flex flex-col gap-1"> <b style=" width: 200px; overflow: hidden; text-overflow: ellipsis; " >{{ token[1] }}</b > {% if is_helper %} <span class="flex gap-2 items-center"> <span class="fade" ><a href="/api/v1/auth/user/find_by_ip/{{ token[0] }}" ><code>{{ token[0] }}</code></a ></span > </span> {% else %} <span class="fade"><code>{{ token[0] }}</code></span> {% endif %} <span class="fade date">{{ token[2] }}</span> </div> <button class="quaternary red" onclick="remove_token('{{ token[1] }}')" > {{ text "general:action.delete" }} </button> </div> {% endfor %} </div> <div class="w-full hidden flex flex-col gap-2" data-tab="theme"> <div class="card tertiary flex flex-col gap-2" id="theme_settings"> {% if failing_color_keys|length > 0 %} <div class="card flex flex-col gap-2" style="background: white; color: black" ui_ident="awful_contrast" > <div class="flex gap-2 items-center"> {{ icon "contrast" }} <b>Some of your custom colors fail contrast checks:</b> </div> <ul> {% for key in failing_color_keys %} <li>{{ key[0] }} <b>{{ key[1] }} < 4.5</b></li> {% endfor %} </ul> </div> {% endif %} <div class="card w-full flex flex-wrap gap-2" ui_ident="import_export" > <button class="primary" onclick="import_theme_settings()"> {{ icon "upload" }} <span>{{ text "settings:label.import" }}</span> </button> <button class="secondary" onclick="export_theme_settings()"> {{ icon "download" }} <span>{{ text "settings:label.export" }}</span> </button> </div> <div class="card-nest" ui_ident="theme_preference"> <div class="card small"> <b>Theme preference</b> </div> <div class="card"> <select onchange="set_setting_field('theme_preference', event.target.selectedOptions[0].value)" > <option value="Auto" selected="{% if user.settings.theme_preference == 'Auto' %}true{% else %}false{% endif %}" > Auto </option> <option value="Light" selected="{% if user.settings.theme_preference == 'Light' %}true{% else %}false{% endif %}" > Light </option> <option value="Dark" selected="{% if user.settings.theme_preference == 'Dark' %}true{% else %}false{% endif %}" > Dark </option> </select> <span class="fade" >This represents your local site theme.</span > </div> </div> <div class="card-nest" ui_ident="profile_theme"> <div class="card small"> <b>Profile theme base</b> </div> <div class="card"> <select onchange="set_setting_field('profile_theme', event.target.selectedOptions[0].value)" > <option value="Auto" selected="{% if user.settings.profile_theme == 'Auto' %}true{% else %}false{% endif %}" > Auto </option> <option value="Light" selected="{% if user.settings.profile_theme == 'Light' %}true{% else %}false{% endif %}" > Light </option> <option value="Dark" selected="{% if user.settings.profile_theme == 'Dark' %}true{% else %}false{% endif %}" > Dark </option> </select> <span class="fade" >This represents the site theme shown to users viewing your profile.</span > </div> </div> </div> <button onclick="save_settings()" id="save_button"> {{ icon "check" }} <span>{{ text "general:action.save" }}</span> </button> </div> <!-- prettier-ignore --> <script type="application/json" id="settings_json">{{ user_settings_serde|safe }}</script> <script> setTimeout(() => { const ui = ns("ui"); const settings = JSON.parse( document.getElementById("settings_json").innerHTML, ); let tokens = JSON.parse("{{ user_tokens_serde|safe }}"); globalThis.remove_token = async (id) => { if ( !(await trigger("atto::confirm", [ "Are you sure you would like to do this?", ])) ) { return; } // reconstruct tokens (but without the token with the given id) const new_tokens = []; for (const token of tokens) { if (token[1] === id) { continue; } new_tokens.push(token); } tokens = new_tokens; // send request to save fetch("/api/v1/auth/user/{{ profile.id }}/tokens", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(tokens), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }; globalThis.save_settings = () => { fetch("/api/v1/auth/user/{{ profile.id }}/settings", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(settings), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }; globalThis.change_password = (e) => { e.preventDefault(); fetch("/api/v1/auth/user/{{ profile.id }}/password", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ from: e.target.current_password.value, to: e.target.new_password.value, }), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }; globalThis.change_username = async (e) => { e.preventDefault(); if ( !(await trigger("atto::confirm", [ "Are you sure you would like to do this?", ])) ) { return; } fetch("/api/v1/auth/user/{{ profile.id }}/username", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ to: e.target.new_username.value, }), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }; globalThis.delete_account = async (e) => { e.preventDefault(); if ( !(await trigger("atto::confirm", [ "Are you sure you would like to do this?", ])) ) { return; } fetch("/api/v1/auth/user/{{ profile.id }}", { method: "DELETE", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ password: e.target.current_password.value, }), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }; globalThis.upload_avatar = (e) => { e.preventDefault(); e.target.querySelector("button").style.display = "none"; fetch("/api/v1/auth/upload/avatar", { method: "POST", body: e.target.file.files[0], }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); e.target .querySelector("button") .removeAttribute("style"); }); alert("Avatar upload in progress. Please wait!"); }; globalThis.upload_banner = (e) => { e.preventDefault(); e.target.querySelector("button").style.display = "none"; fetch("/api/v1/auth/upload/banner", { method: "POST", body: e.target.file.files[0], }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); e.target .querySelector("button") .removeAttribute("style"); }); alert("Banner upload in progress. Please wait!"); }; globalThis.enable_totp = async (event) => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this? You must have access to your TOTP codes to disable TOTP.", ])) ) { return; } fetch("/api/v1/auth/user/{{ user.id }}/totp", { method: "POST", headers: { "Content-Type": "application/json", }, }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); const [secret, qr, recovery_codes] = res.payload; document.getElementById("totp_secret").innerText = secret; document.getElementById("totp_qr").src = `data:image/png;base64,${qr}`; document.getElementById( "totp_recovery_codes", ).innerText = recovery_codes.join("\n"); document.getElementById("totp_stuff").style.display = "contents"; event.target.remove(); }); }; globalThis.disable_totp = async (event) => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this?", ])) ) { return; } const totp_code = await trigger("atto::prompt", ["TOTP code:"]); if (!totp_code) { return; } fetch("/api/v1/auth/user/{{ profile.id }}/totp", { method: "DELETE", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ totp: totp_code }), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); event.target.remove(); }); }; globalThis.refresh_totp_codes = async (event) => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this? The old codes will no longer work.", ])) ) { return; } const totp_code = await trigger("atto::prompt", ["TOTP code:"]); if (!totp_code) { return; } fetch("/api/v1/auth/user/{{ profile.id }}/totp/codes", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ totp: totp_code }), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); document.getElementById( "totp_recovery_codes", ).innerText = res.payload.join("\n"); document.getElementById( "totp_recovery_codes", ).style.display = "block"; event.target.remove(); }); }; const account_settings = document.getElementById("account_settings"); const profile_settings = document.getElementById("profile_settings"); const theme_settings = document.getElementById("theme_settings"); ui.refresh_container(account_settings, [ "change_password", "change_username", "two_factor_authentication", ]); ui.refresh_container(profile_settings, [ "change_avatar", "change_banner", ]); ui.refresh_container(theme_settings, [ "awful_contrast", "import_export", "theme_preference", "profile_theme", ]); ui.generate_settings_ui( account_settings, [ [ ["display_name", "Display name"], "{{ profile.settings.display_name }}", "input", ], [ ["biography", "Biography"], settings.biography, "textarea", ], [ ["warning", "Profile warning"], settings.warning, "textarea", ], ], settings, ); ui.generate_settings_ui( profile_settings, [ [[], "Privacy", "title"], [ [ "private_profile", "Only allow users I'm following to view my profile", ], "{{ profile.settings.private_profile }}", "checkbox", ], [ [ "private_communities", "Keep my joined communities private", ], "{{ profile.settings.private_communities }}", "checkbox", ], [ ["private_last_seen", "Keep my last seen time private"], "{{ profile.settings.private_last_seen }}", "checkbox", ], [[], "Questions", "title"], [ [ "enable_questions", "Allow users to ask you questions", ], "{{ profile.settings.enable_questions }}", "checkbox", ], [ [ "allow_anonymous_questions", "Allow anonymous questions", ], "{{ profile.settings.allow_anonymous_questions }}", "checkbox", ], [ ["motivational_header", "Motivational header"], settings.motivational_header, "input", ], [[], "Anonymous", "title"], [ ["anonymous_username", "Anonymous username"], settings.anonymous_username, "input", ], [ ["anonymous_avatar_url", "Anonymous avatar URL"], settings.anonymous_avatar_url, "input", ], ], settings, ); const can_use_custom_css = "{{ user.permissions|has_supporter }}" === "true"; const theme_settings_ui_json = [ [ [ "disable_other_themes", "Disable the profile theme of other users", ], "{{ profile.settings.disable_other_themes }}", "checkbox", ], [[], "Theme builder", "title"], [ [], "Allow the site to build the theme for you given a base hue, saturation, and lightness. Scroll down to the next section to manually build the theme.", "text", ], [ ["theme_hue", "Theme hue (integer 0-255)"], "{{ profile.settings.theme_hue }}", "input", ], [ ["theme_sat", "Theme sat (percentage 0%-100%)"], "{{ profile.settings.theme_sat }}", "input", ], [ ["theme_lit", "Theme lit (percentage 0%-100%)"], "{{ profile.settings.theme_lit }}", "input", ], [[], "Manual theme builder", "title"], [[], "Override individual colors.", "text"], // surface [ ["theme_color_surface", "Surface"], "{{ profile.settings.theme_color_surface }}", "color", { description: "Page background.", }, ], [ ["theme_color_text", "Text"], "{{ profile.settings.theme_color_text }}", "color", { description: "Text on elements with the surface backgrounds.", }, ], [ ["theme_color_text_link", "Links"], "{{ profile.settings.theme_color_text_link }}", "color", { description: "Links on all elements.", }, ], // lowered [[], "", "divider"], [ ["theme_color_lowered", "Lowered"], "{{ profile.settings.theme_color_lowered }}", "color", { description: "Some cards, buttons, or anything else with a darker background color than the surface.", }, ], [ ["theme_color_text_lowered", "Text"], "{{ profile.settings.theme_color_text_lowered }}", "color", { description: "Text on elements with the lowered backgrounds.", }, ], [ ["theme_color_super_lowered", "Super lowered"], "{{ profile.settings.theme_color_super_lowered }}", "color", { description: "Borders.", }, ], // raised [[], "", "divider"], [ ["theme_color_raised", "Raised"], "{{ profile.settings.theme_color_raised }}", "color", { description: "Some cards, buttons, or anything else with a lighter background color than the surface.", }, ], [ ["theme_color_text_raised", "Text"], "{{ profile.settings.theme_color_text_raised }}", "color", { description: "Text on elements with the raised backgrounds.", }, ], [ ["theme_color_super_raised", "Super raised"], "{{ profile.settings.theme_color_super_raised }}", "color", { description: "Some borders.", }, ], // primary [[], "", "divider"], [ ["theme_color_primary", "Primary"], "{{ profile.settings.theme_color_primary }}", "color", { description: "Primary color; navigation bar, some buttons, etc.", }, ], [ ["theme_color_text_primary", "Text"], "{{ profile.settings.theme_color_text_primary }}", "color", { description: "Text on elements with the primary backgrounds.", }, ], [ ["theme_color_primary_lowered", "Lowered"], "{{ profile.settings.theme_color_primary_lowered }}", "color", { description: "Hover state for primary buttons.", }, ], // secondary [[], "", "divider"], [ ["theme_color_secondary", "Secondary"], "{{ profile.settings.theme_color_secondary }}", "color", { description: "Secondary color.", }, ], [ ["theme_color_text_secondary", "Text"], "{{ profile.settings.theme_color_text_secondary }}", "color", { description: "Text on elements with the secondary backgrounds.", }, ], [ ["theme_color_secondary_lowered", "Lowered"], "{{ profile.settings.theme_color_secondary_lowered }}", "color", { description: "Hover state for secondary buttons.", }, ], ]; if (can_use_custom_css) { theme_settings_ui_json.push([[], "Advanced", "title"]); theme_settings_ui_json.push([ ["theme_custom_css", "Custom CSS"], settings.theme_custom_css, "textarea", { description: "Custom CSS input embedded into your theme.", }, ]); } ui.generate_settings_ui( theme_settings, theme_settings_ui_json, settings, ); globalThis.import_theme_settings = () => { const input = document.createElement("input"); input.type = "file"; input.accept = "application/json"; document.body.appendChild(input); input.addEventListener("change", async (e) => { const json = JSON.parse(await e.target.files[0].text()); for (const setting of Object.entries(json)) { settings[setting[0]] = setting[1]; } input.remove(); save_settings(); setTimeout(() => { window.location.reload(); }, 150); }); input.click(); }; globalThis.export_theme_settings = () => { const theme_settings = { profile_theme: settings.profile_theme, }; for (const setting of Object.entries(settings)) { if (setting[0].startsWith("theme_")) { theme_settings[setting[0]] = setting[1]; } } const blob = new Blob( [JSON.stringify(theme_settings, null, 4)], { type: "appliction/json", }, ); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.setAttribute("download", "theme.json"); document.body.appendChild(anchor); anchor.click(); anchor.remove(); }; }); </script> </main> {% endblock %}