(text "{% extends \"root.html\" %} {% block head %}") (title (text "Settings - {{ config.name }}")) (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") (text "{% if profile.id != user.id -%}") (div ("class" "card w-full red flex gap-2 items-center") (text "{{ icon \"skull\" }}") (b (text "Editing other user's settings! Please be careful."))) (text "{%- endif %}") (div ("class" "pillmenu") (a ("data-tab-button" "account") ("class" "active") ("href" "#/account") (text "{{ icon \"smile\" }}") (span (text "{{ text \"settings:tab.account\" }}"))) (a ("data-tab-button" "profile") ("href" "#/profile") (text "{{ icon \"user-round\" }}") (span (text "{{ text \"settings:tab.profile\" }}"))) (a ("data-tab-button" "theme") ("href" "#/theme") (text "{{ icon \"paint-bucket\" }}") (span (text "{{ text \"settings:tab.theme\" }}"))) (a ("data-tab-button" "sessions") ("href" "#/sessions") (text "{{ icon \"cookie\" }}") (span (text "{{ text \"settings:tab.sessions\" }}"))) (a ("data-tab-button" "connections") ("href" "#/connections") (text "{{ icon \"cable\" }}") (span (text "{{ text \"settings:tab.connections\" }}")))) (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" "pillmenu") ("ui_ident" "account_settings_tabs") (a ("data-tab-button" "account/security") ("href" "#/account/security") (text "{{ icon \"user-lock\" }}") (span (text "{{ text \"settings:tab.security\" }}"))) (a ("data-tab-button" "account/following") ("href" "#/account/following") (text "{{ icon \"rss\" }}") (span (text "{{ text \"auth:label.following\" }}"))) (a ("data-tab-button" "account/blocks") ("href" "#/account/blocks") (text "{{ icon \"shield\" }}") (span (text "{{ text \"settings:tab.blocks\" }}"))) (a ("data-tab-button" "account/uploads") ("href" "?page=0#/account/uploads") (text "{{ icon \"image-up\" }}") (span (text "{{ text \"settings:tab.uploads\" }}"))) (text "{% if config.stripe -%}") (a ("data-tab-button" "account/billing") ("href" "#/account/billing") (text "{{ icon \"credit-card\" }}") (span (text "{{ text \"settings:tab.billing\" }}"))) (text "{%- endif %}")) (div ("class" "card-nest") ("ui_ident" "home_timeline") (div ("class" "card small") (b (text "Home timeline"))) (div ("class" "card") (select ("onchange" "set_setting_field('default_timeline', event.target.selectedOptions[0].value.startsWith('{') ? JSON.parse(event.target.selectedOptions[0].value) : event.target.selectedOptions[0].value)") (option ("value" "MyCommunities") ("selected" "{% if home == '/' -%}true{% else %}false{%- endif %}") (text "My communities")) (option ("value" "MyCommunitiesQuestions") ("selected" "{% if home == '/questions' -%}true{% else %}false{%- endif %}") (text "My communities (questions)")) (option ("value" "PopularPosts") ("selected" "{% if home == '/popular' -%}true{% else %}false{%- endif %}") (text "Popular")) (option ("value" "PopularQuestions") ("selected" "{% if home == '/popular/questions' -%}true{% else %}false{%- endif %}") (text "Popular (questions)")) (option ("value" "FollowingPosts") ("selected" "{% if home == '/following' -%}true{% else %}false{%- endif %}") (text "Following")) (option ("value" "FollowingQuestions") ("selected" "{% if home == '/following/questions' -%}true{% else %}false{%- endif %}") (text "Following (questions)")) (option ("value" "AllPosts") ("selected" "{% if home == '/all' -%}true{% else %}false{%- endif %}") (text "All")) (option ("value" "AllQuestions") ("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}") (text "All (questions)")) (text "{% for stack in stacks %}") (option ("value" "{\\\"Stack\\\":\\\"{{ stack.id }}\\\"}") ("selected" "{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}") (text "{{ stack.name }} (stack)")) (text "{% endfor %}")) (span ("class" "fade") (text "This represents the timeline the home button takes you to.")))) (div ("class" "card-nest desktop") ("ui_ident" "notifications") (div ("class" "card small") (b (text "Notifications"))) (div ("class" "card flex flex-col gap-2") (button ("id" "notifications_button")) (span ("class" "fade") (text "Notifications require you to keep {{ config.name }} open in your browser for real-time updates. This setting does not sync across browsers.")))) (script (text "setTimeout(() => { trigger(\"me::notifications_button\", [ document.getElementById(\"notifications_button\"), ]); }, 150);")) (div ("class" "card-nest") ("ui_ident" "change_username") (div ("class" "card small") (b (text "{{ text \"settings:label.change_username\" }}"))) (form ("class" "card flex flex-col gap-2") ("onsubmit" "change_username(event)") (div ("class" "flex flex-col gap-1") (label ("for" "new_username") (text "{{ text \"settings:label.new_username\" }}")) (input ("type" "text") ("name" "new_username") ("id" "new_username") ("placeholder" "new_username") ("required" "") ("minlength" "2"))) (button ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) (div ("class" "card-nest") ("ui_ident" "delete_account") (div ("class" "card small flex items-center gap-2 red") (text "{{ icon \"skull\" }}") (b (text "{{ text \"settings:label.delete_account\" }}"))) (form ("class" "card flex flex-col gap-2") ("onsubmit" "delete_account(event)") (div ("class" "flex flex-col gap-1") (label ("for" "current_password") (text "{{ text \"settings:label.current_password\" }}")) (input ("type" "password") ("name" "current_password") ("id" "current_password") ("placeholder" "current_password") ("required" "") ("minlength" "6") ("autocomplete" "off"))) (button ("class" "primary") (text "{{ icon \"trash\" }}") (span (text "{{ text \"general:action.delete\" }}"))))) (button ("onclick" "save_settings()") ("id" "save_button") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))) (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/security") (div ("class" "card tertiary flex flex-col gap-2") (a ("href" "#/account") ("class" "button secondary") (text "{{ icon \"arrow-left\" }}") (span (text "{{ text \"general:action.back\" }}"))) (div ("class" "card-nest") (div ("class" "card flex items-center gap-2 small") (text "{{ icon \"user-lock\" }}") (span (text "{{ text \"settings:tab.security\" }}"))) (div ("class" "card flex flex-col gap-2 secondary") (div ("class" "card-nest") ("ui_ident" "two_factor_authentication") (div ("class" "card small") (b (text "{{ text \"settings:label.two_factor_authentication\" }}"))) (div ("class" "card flex flex-col gap-2") (text "{% if profile.totp|length == 0 -%}") (div ("id" "totp_stuff") ("style" "display: none") (span (text "Scan this QR code in a TOTP authenticator app (like Google Authenticator):")) (img ("id" "totp_qr") ("style" "max-width: 250px")) (span (text "TOTP secret (do NOT share):")) (pre ("id" "totp_secret")) (span (text "Recovery codes (STORE SAFELY, these can only be viewed once):")) (pre ("id" "totp_recovery_codes"))) (button ("class" "quaternary green") ("onclick" "enable_totp(event)") (text "Enable TOTP 2FA")) (text "{% else %}") (pre ("id" "totp_recovery_codes") ("style" "display: none")) (div ("class" "flex gap-2 flex-wrap") (button ("class" "quaternary red") ("onclick" "refresh_totp_codes(event)") (text "Refresh recovery codes")) (button ("class" "quaternary red") ("onclick" "disable_totp(event)") (text "Disable TOTP 2FA"))) (text "{%- endif %}"))) (div ("class" "card-nest") ("ui_ident" "change_password") (div ("class" "card small") (b (text "{{ text \"settings:label.change_password\" }}"))) (form ("class" "card flex flex-col gap-2") ("onsubmit" "change_password(event)") (div ("class" "flex flex-col gap-1") (label ("for" "current_password") (text "{{ text \"settings:label.current_password\" }}")) (input ("type" "password") ("name" "current_password") ("id" "current_password") ("placeholder" "current_password") ("required" "") ("minlength" "6") ("autocomplete" "off"))) (div ("class" "flex flex-col gap-1") (label ("for" "new_password") (text "{{ text \"settings:label.new_password\" }}")) (input ("type" "password") ("name" "new_password") ("id" "new_password") ("placeholder" "new_password") ("required" "") ("minlength" "6") ("autocomplete" "off"))) (button ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))))))) (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/following") (div ("class" "card tertiary flex flex-col gap-2") (a ("href" "#/account") ("class" "button secondary") (text "{{ icon \"arrow-left\" }}") (span (text "{{ text \"general:action.back\" }}"))) (div ("class" "card-nest") (div ("class" "card flex items-center gap-2 small") (text "{{ icon \"rss\" }}") (span (text "{{ text \"auth:label.following\" }}"))) (div ("class" "card flex flex-col gap-2") (text "{% for userfollow in following %} {% set user = userfollow[1] %}") (div ("class" "card secondary flex flex-wrap gap-2 items-center justify-between") (div ("class" "flex gap-2") (text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}")) (div ("class" "flex gap-2") (button ("class" "quaternary red small") ("onclick" "toggle_follow_user('{{ user.id }}')") (text "{{ icon \"user-minus\" }}") (span (text "{{ text \"auth:action.unfollow\" }}"))) (a ("href" "/@{{ user.username }}") ("class" "button quaternary small") (text "{{ icon \"external-link\" }}") (span (text "{{ text \"requests:action.view_profile\" }}"))))) (text "{% endfor %}")))) (script (text "globalThis.toggle_follow_user = async (uid) => { await trigger(\"atto::debounce\", [\"users::follow\"]); fetch(`/api/v1/auth/user/${uid}/follow`, { method: \"POST\", }) .then((res) => res.json()) .then((res) => { trigger(\"atto::toast\", [ res.ok ? \"success\" : \"error\", res.message, ]); }); };"))) (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/blocks") (div ("class" "card tertiary flex flex-col gap-2") (a ("href" "#/account") ("class" "button secondary") (text "{{ icon \"arrow-left\" }}") (span (text "{{ text \"general:action.back\" }}"))) (div ("class" "card-nest") (div ("class" "card flex items-center gap-2 small") (text "{{ icon \"users-round\" }}") (span (text "{{ text \"settings:label.users\" }}"))) (div ("class" "card flex flex-col gap-2") (text "{% for user in blocks %}") (div ("class" "card secondary flex flex-wrap gap-2 items-center justify-between") (div ("class" "flex gap-2") (text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}")) (a ("href" "/@{{ user.username }}") ("class" "button quaternary small") (text "{{ icon \"external-link\" }}") (span (text "{{ text \"requests:action.view_profile\" }}")))) (text "{% endfor %}"))))) (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/uploads") (div ("class" "card tertiary flex flex-col gap-2") (a ("href" "#/account") ("class" "button secondary") (text "{{ icon \"arrow-left\" }}") (span (text "{{ text \"general:action.back\" }}"))) (div ("class" "card-nest") (div ("class" "card flex items-center gap-2 small") (text "{{ icon \"image-up\" }}") (span (text "{{ text \"settings:tab.uploads\" }}"))) (div ("class" "card flex flex-col gap-2 secondary") (text "{{ components::supporter_ad(body=\"Become a supporter to upload images directly to posts!\") }} {% for upload in uploads %}") (div ("class" "card flex flex-wrap gap-2 items-center justify-between") (div ("class" "flex gap-2 items-center") ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") ("style" "cursor: pointer") (text "{{ icon \"file-image\" }}") (b (span ("class" "date") (text "{{ upload.created }}")) (text "({{ upload.what }})"))) (div ("class" "flex gap-2") (button ("class" "quaternary small") ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") (text "{{ icon \"view\" }}") (span (text "{{ text \"general:action.view\" }}"))) (button ("class" "quaternary small red") ("onclick" "remove_upload('{{ upload.id }}')") (text "{{ icon \"x\" }}") (span (text "{{ text \"stacks:label.remove\" }}"))))) (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") (script (text "globalThis.remove_upload = async (id) => { if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this? This action is permanent.\", ])) ) { return; } fetch(`/api/v1/uploads/${id}`, { method: \"DELETE\", }) .then((res) => res.json()) .then((res) => { trigger(\"atto::toast\", [ res.ok ? \"success\" : \"error\", res.message, ]); }); };")))))) (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/billing") (div ("class" "card tertiary flex flex-col gap-2") (a ("href" "#/account") ("class" "button secondary") (text "{{ icon \"arrow-left\" }}") (span (text "{{ text \"general:action.back\" }}"))) (div ("class" "card-nest") (div ("class" "card flex items-center gap-2 small") (text "{{ icon \"credit-card\" }}") (span (text "{{ text \"settings:tab.billing\" }}"))) (div ("class" "card flex flex-col gap-2 secondary") (text "{% if config.stripe -%}") (div ("class" "card-nest") ("ui_ident" "supporter_card") (div ("class" "card small flex items-center gap-2") (text "{{ icon \"star\" }}") (b (text "Supporter status"))) (div ("class" "card flex flex-col gap-2") (text "{% if is_supporter -%}") (p (text "You ") (b (text "are ")) (text "a supporter! Thank you for all that you do. You can manage your billing information below.") (b (text "Please use your email address you supplied when paying to login to the billing portal."))) (a ("href" "{{ config.stripe.billing_portal_url }}") ("class" "button quaternary") ("target" "_blank") (text "Manage billing")) (text "{% else %}") (p (text "You're ") (b (text "not ")) (text "currently a supporter! No pressure, but it helps us do some pretty cool things! As a supporter, you'll get:")) (ul ("style" "margin-bottom: var(--pad-4)") (li (text "Vanity badge on profile")) (li (text "No more supporter ads (duh)")) (li (text "Ability to upload gif avatars/banners")) (li (text "Be an admin/owner of up to 10 communities")) (li (text "Use custom CSS on your profile")) (li (text "Ability to use community emojis outside of their community")) (li (text "Ability to upload and use gif emojis")) (li (text "Create infinite stack timelines")) (li (text "Ability to upload images to posts")) (li (text "Save infinite post drafts")) (li (text "Ability to search through all posts")) (li (text "Ability to create forges"))) (a ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("class" "button") ("target" "_blank") (text "Become a supporter")) (span ("class" "fade") (text "Please use your") (b (text "real email")) (text "when completing payment. It is required to manage your billing settings.")) (text "{%- endif %}"))) (text "{%- endif %}"))))) (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") (text "{{ components::supporter_ad(body=\"Become a supporter to upload GIF images!\") }}") (div ("class" "card-nest") ("ui_ident" "change_avatar") (div ("class" "card small") (b (text "{{ text \"settings:label.change_avatar\" }}"))) (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,image/gif") ("class" "w-content")) (button ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") (text "Images must be less than 8 MB large. Animated GIFs are only supported for supporter users. GIFs can be at most 2 MB large.")))) (div ("class" "card-nest") ("ui_ident" "change_banner") (div ("class" "card small") (b (text "{{ text \"settings:label.change_banner\" }}"))) (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,image/gif") ("class" "w-content")) (button ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") (text "Use an image of 1100x350px for the best results."))))) (button ("onclick" "save_settings()") ("id" "save_button") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))) (div ("class" "card w-full tertiary hidden flex flex-col gap-2") ("data-tab" "sessions") (text "{% 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; ") (text "{{ token[1] }}")) (text "{% 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 (text "{{ token[0] }}"))))) (text "{% else %}") (span ("class" "fade") (code (text "{{ token[0] }}"))) (text "{%- endif %}") (span ("class" "fade date") (text "{{ token[2] }}"))) (button ("class" "quaternary red") ("onclick" "remove_token('{{ token[1] }}')") (text "{{ text \"general:action.delete\" }}"))) (text "{% endfor %}")) (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") (text "{% 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") (span ("class" "desktop") ("style" "display: contents") (text "{{ icon \"contrast\" }}")) (b (text "Some of your custom colors fail contrast checks:"))) (ul (text "{% for key in failing_color_keys %}") (li (text "{{ key[0] }}") (b (text "{{ key[1] }} < 4.5"))) (text "{% endfor %}"))) (text "{%- endif %}") (div ("class" "card w-full flex flex-wrap gap-2") ("ui_ident" "import_export") (button ("class" "primary") ("onclick" "import_theme_settings()") (text "{{ icon \"upload\" }}") (span (text "{{ text \"settings:label.import\" }}"))) (button ("class" "secondary") ("onclick" "export_theme_settings()") (text "{{ icon \"download\" }}") (span (text "{{ text \"settings:label.export\" }}")))) (text "{{ components::supporter_ad(body=\"Become a supporter to add custom CSS!\") }}") (div ("class" "card-nest") ("ui_ident" "theme_preference") (div ("class" "card small") (b (text "Theme preference"))) (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 %}") (text "Auto")) (option ("value" "Light") ("selected" "{% if user.settings.theme_preference == 'Light' -%}true{% else %}false{%- endif %}") (text "Light")) (option ("value" "Dark") ("selected" "{% if user.settings.theme_preference == 'Dark' -%}true{% else %}false{%- endif %}") (text "Dark"))) (span ("class" "fade") (text "This represents your local site theme.")))) (div ("class" "card-nest") ("ui_ident" "profile_theme") (div ("class" "card small") (b (text "Profile theme base"))) (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 %}") (text "Auto")) (option ("value" "Light") ("selected" "{% if user.settings.profile_theme == 'Light' -%}true{% else %}false{%- endif %}") (text "Light")) (option ("value" "Dark") ("selected" "{% if user.settings.profile_theme == 'Dark' -%}true{% else %}false{%- endif %}") (text "Dark"))) (span ("class" "fade") (text "This represents the site theme shown to users viewing your profile."))))) (button ("onclick" "save_settings()") ("id" "save_button") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))) (div ("class" "card w-full tertiary hidden flex flex-col gap-2") ("data-tab" "connections") (div ("class" "card w-full flex flex-wrap gap-2") (text "{% if config.connections.spotify_client_id and not profile.connections.Spotify %}") (button ("class" "quaternary") ("onclick" "trigger('spotify::create_connection', ['{{ config.connections.spotify_client_id }}'])") (text "{{ icon \"spotify\" }}") (span (text "Spotify"))) (text "{%- endif %} {% if config.connections.last_fm_key and not profile.connections.LastFm %}") (button ("class" "quaternary") ("onclick" "trigger('last_fm::create_connection', ['{{ config.connections.last_fm_key }}'])") (text "{{ icon \"last_fm\" }}") (span (text "Last.fm"))) (text "{%- endif %}")) (text "{% for key, value in profile.connections %}") (div ("class" "card-nest") (div ("class" "card small flex items-center gap-2") (text "{{ components::connection_icon(key=key) }}") (b ("class" "flex items-center gap-2") (text "{% if value[0].data.name -%}") (span (text "{{ value[0].data.name }}")) (span ("style" "display: contents;") ("title" "Verified connection") (text "{{ icon \"badge-check\" }}")) (text "{% else %}") (span (text "{{ key }}")) (span ("style" "display: contents;") (text "{{ icon \"badge-alert\" }}")) (text "{%- endif %}"))) (div ("class" "card flex flex-col gap-2") (button ("class" "quaternary red small") ("onclick" "trigger('connections::delete', ['{{ key }}'])") (text "{{ text \"general:action.delete\" }}")) (label ("for" "{{ key }}-shown") ("class" "flex items-center gap-2") (input ("type" "checkbox") ("checked" "{% if value[0].show_on_profile -%}true{% else %}false{%- endif %}") ("id" "{{ key }}-shown") ("onchange" "trigger('connections::push_con_shown', ['{{ key }}', event.target.checked])") ("class" "w-content")) (span (text "Shown on profile"))))) (text "{% endfor %}")) (script ("type" "application/json") ("id" "settings_json") (text "{{ profile.settings|json_encode()|remove_script_tags|safe }}")) (script (text "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, [ \"supporter_ad\", \"account_settings_tabs\", \"home_timeline\", \"notifications\", \"change_username\", \"delete_account\", ]); ui.refresh_container(profile_settings, [ \"supporter_ad\", \"change_avatar\", \"change_banner\", ]); ui.refresh_container(theme_settings, [ \"supporter_ad\", \"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\", ], [[\"status\", \"Status\"], settings.status, \"textarea\"], [ [\"warning\", \"Profile warning\"], settings.warning, \"textarea\", ], ], settings, ); ui.generate_settings_ui( profile_settings, [ [[], \"Privacy\", \"title\"], [ [ \"require_account\", \"Require an account to view my profile\", ], \"{{ profile.settings.require_account }}\", \"checkbox\", ], [ [ \"private_profile\", \"Only allow users I'm following to view my profile\", ], \"{{ profile.settings.private_profile }}\", \"checkbox\", ], [ [ \"private_chats\", \"Only allow users I'm following to add me to chats\", ], \"{{ profile.settings.private_chats }}\", \"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\", ], [ [\"hide_extra_post_tabs\", \"Hide extra post tabs (replies, media)\"], \"{{ profile.settings.hide_extra_post_tabs }}\", \"checkbox\", ], [ [\"show_nsfw\", \"Show NSFW posts\"], \"{{ profile.settings.show_nsfw }}\", \"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\", ], [[], \"Misc\", \"title\"], [ [\"hide_dislikes\", \"Hide post dislikes\"], \"{{ profile.settings.hide_dislikes }}\", \"checkbox\", ], [ [], \"Hides dislikes on all posts. Users can still dislike your posts, you just won't be able to see it.\", \"text\", ], ], 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 background.\", }, ], [ [\"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\", { embed_html: '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(); }; });"))) (text "{% endblock %}")