(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 %}") ; nav (div ("class" "mobile_nav mobile") ; primary nav (div ("class" "dropdown") ("style" "width: max-content") (button ("class" "camo raised small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") (icon (text "sliders-horizontal")) (span ("class" "current_tab_text") (text "account"))) (div ("class" "inner left") (text "{{ macros::profile_settings_nav_options() }}")))) ; nav desktop (div ("class" "desktop pillmenu") (text "{{ macros::profile_settings_nav_options() }}")) ; ... (div ("class" "w-full flex flex-col gap-2") ("data-tab" "account") (div ("class" "card lowered 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" "window.SETTING_SET_FUNCTIONS[0]('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 lowered 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" "lowered 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" "lowered red") ("onclick" "refresh_totp_codes(event)") (text "Refresh recovery codes")) (button ("class" "lowered 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 lowered 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" "lowered red small") ("onclick" "toggle_follow_user('{{ user.id }}')") (text "{{ icon \"user-minus\" }}") (span (text "{{ text \"auth:action.unfollow\" }}"))) (a ("href" "/@{{ user.username }}") ("class" "button lowered 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 lowered flex flex-col gap-2") (a ("href" "#/account") ("class" "button secondary") (text "{{ icon \"arrow-left\" }}") (span (text "{{ text \"general:action.back\" }}"))) ; stack blocks (div ("class" "card-nest") (div ("class" "card flex items-center gap-2 small") (text "{{ icon \"layers\" }}") (span (text "{{ text \"stacks:link.stacks\" }}"))) (div ("class" "card flex flex-col gap-2") (text "{% for stack in stackblocks %}") (text "{{ components::stack_listing(stack=stack) }}") (text "{% endfor %}"))) ; user blocks (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 lowered 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 lowered 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" "lowered small") ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") (text "{{ icon \"view\" }}") (span (text "{{ text \"general:action.view\" }}"))) (button ("class" "lowered 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 lowered 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 lowered") ("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 "Use community emojis outside of their community")) (li (text "Upload and use gif emojis")) (li (text "Create infinite stack timelines")) (li (text "Upload images to posts")) (li (text "Save infinite post drafts")) (li (text "Ability to search through all posts")) (li (text "Ability to create forges")) (li (text "Create more than 1 app")) (li (text "Create up to 10 stack blocks")) (li (text "Add unlimited users to stacks")) (li (text "Increased proxied image size")) (li (text "Create infinite journals")) (li (text "Create infinite notes in each journal"))) (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 lowered 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 lowered 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" "lowered 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 lowered 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" "window.SETTING_SET_FUNCTIONS[0]('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" "window.SETTING_SET_FUNCTIONS[0]('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 lowered 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" "lowered") ("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" "lowered") ("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" "lowered 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 %}") (text "{% for grant in profile_grants %}") (div ("class" "card-nest") (div ("class" "card small flex items-center gap-4") (div ("class" "flex items-center gap-2") (icon (text "bot")) (a ("class" "flush") ("href" "{{ grant[0].homepage }}") ("target" "_blank") (b ("class" "flex items-center gap-2") (text "{{ grant[0].title }}")))) (span ("class" "fade flex items-center gap-2") (icon (text "clock")) (span ("class" "date") (text "{{ grant[1].last_updated }}")))) (div ("class" "card flex flex-col gap-2") (details (summary (icon (text "scan-eye")) (text "{{ grant[1].scopes|length }} scope{{ grant[1].scopes|length|pluralize }}")) (div ("class" "card lowered w-full") (ul (text "{% for scope in grant[1].scopes -%}") (li (a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variant.{{ scope }}") ("target" "_blank") (text "{{ scope }}"))) (text "{%- endfor %}")))) (button ("class" "lowered red small") ("onclick" "remove_grant('{{ grant[0].id }}')") (text "{{ text \"general:action.delete\" }}")))) (text "{% endfor %}") (hr) (a ("class" "button") ("href" "/developer") (icon (text "code")) (span (text "{{ config.name }} ") (str (text "developer:label.for_developers"))))) (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.remove_grant = async (id) => { if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this?\", ])) ) { return; } fetch(`/api/v1/auth/user/{{ profile.id }}/grants/${id}`, { method: \"DELETE\", }) .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\", ], [[\"muted\", \"Muted phrases\"], settings.muted.join(\"\\n\"), \"textarea\", { embed_html: 'Muted phrases should all be on new lines.', }], ], settings, { muted: (new_muted) => { settings.muted = new_muted .split(\"\\n\") .map((t) => t.trim()); }, }, ); 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 will also no longer be able to dislike your posts.\", \"text\", ], [ [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"], \"{{ profile.settings.paged_timelines }}\", \"checkbox\", ], [[], \"Fun\", \"title\"], [ [\"disable_gpa_fun\", \"Disable GPA\"], \"{{ profile.settings.disable_gpa_fun }}\", \"checkbox\", ], ], 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 %}")