1911 lines
81 KiB
Common Lisp
1911 lines
81 KiB
Common Lisp
(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" "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\" }}"))))
|
|
|
|
(text "{% if config.stripe -%}")
|
|
; stripe menu
|
|
(div
|
|
("class" "pillmenu")
|
|
("ui_ident" "account_settings_tabs")
|
|
(a
|
|
("data-tab-button" "account/uploads")
|
|
("href" "?page=0#/account/uploads")
|
|
(text "{{ icon \"image-up\" }}")
|
|
(span
|
|
(text "{{ text \"settings:tab.uploads\" }}")))
|
|
(text "{% if config.security.enable_invite_codes -%}")
|
|
(a
|
|
("data-tab-button" "account/invites")
|
|
("href" "?page=0#/account/invites")
|
|
(text "{{ icon \"ticket\" }}")
|
|
(span
|
|
(text "{{ text \"settings:tab.invites\" }}")))
|
|
(text "{%- endif %}")
|
|
(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 %}")
|
|
(text "<option
|
|
value='{\"Stack\":\"{{ stack.id }}\"}'
|
|
selected=\"{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}\"
|
|
>
|
|
{{ stack.name }} (stack)
|
|
</option>")
|
|
(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) }}"))
|
|
(div
|
|
("class" "flex gap-2")
|
|
(a
|
|
("href" "/stacks/add_user/{{ user.id }}")
|
|
("target" "_blank")
|
|
("class" "button lowered small")
|
|
(icon (text "plus"))
|
|
(span (str (text "settings:label.add_to_stack"))))
|
|
(a
|
|
("href" "/@{{ user.username }}")
|
|
("class" "button lowered small")
|
|
(icon (text "external-link"))
|
|
(span (str (text "requests:action.view_profile"))))))
|
|
(text "{% endfor %}")))
|
|
|
|
; ip blocks
|
|
(div
|
|
("class" "card-nest")
|
|
(div
|
|
("class" "card flex items-center gap-2 small")
|
|
(text "{{ icon \"wifi\" }}")
|
|
(span
|
|
(text "{{ text \"settings:label.ips\" }}")))
|
|
(div
|
|
("class" "card flex flex-col gap-2")
|
|
(text "{% for ip in ipblocks %}")
|
|
(div
|
|
("class" "card secondary flex flex-wrap gap-2 items-center justify-between")
|
|
(span
|
|
(text "Block from: ") (span ("class" "date") (text "{{ ip.created }}")))
|
|
(div
|
|
("class" "flex gap-2")
|
|
(button
|
|
("onclick" "trigger('me::remove_ip_block', ['{{ ip.id }}'])")
|
|
("class" "lowered small red")
|
|
(icon (text "x"))
|
|
(span (str (text "auth:action.unblock"))))))
|
|
(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,
|
|
]);
|
|
});
|
|
};"))))))
|
|
|
|
(text "{% if config.security.enable_invite_codes -%}")
|
|
(div
|
|
("class" "w-full flex flex-col gap-2 hidden")
|
|
("data-tab" "account/invites")
|
|
(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 \"ticket\" }}")
|
|
(span
|
|
(text "{{ text \"settings:tab.invites\" }}")))
|
|
(div
|
|
("class" "card flex flex-col gap-2 secondary")
|
|
(pre ("id" "invite_codes_output") ("class" "hidden") (code))
|
|
(pre ("id" "invite_codes_error_output") ("class" "hidden") (code ("class" "red")))
|
|
|
|
(button
|
|
("onclick" "generate_invite_codes()")
|
|
(icon (text "plus"))
|
|
(str (text "settings:label.generate_invites")))
|
|
|
|
(text "{{ components::supporter_ad(body=\"Become a supporter to generate up to 48 invite codes! You can currently have 2 maximum.\") }} {% for code in invites %}")
|
|
(div
|
|
("class" "card flex flex-col gap-2")
|
|
(text "{% if code[1].is_used -%}")
|
|
; used
|
|
(b ("class" "{% if code[1].is_used -%} fade {%- endif %}") (s (text "{{ code[1].code }}")))
|
|
(text "{{ components::full_username(user=code[0]) }}")
|
|
(text "{% else %}")
|
|
; unused
|
|
(b (text "{{ code[1].code }}"))
|
|
(text "{%- endif %}"))
|
|
(text "{% endfor %}")
|
|
(text "{{ components::pagination(page=page, items=invites|length, key=\"#/account/invites\") }}")
|
|
(script
|
|
(text "globalThis.generate_invite_codes = async () => {
|
|
await trigger(\"atto::debounce\", [\"invites::create\"]);
|
|
if (
|
|
!(await trigger(\"atto::confirm\", [
|
|
\"Are you sure you would like to do this? This action is permanent.\",
|
|
]))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const count = Number.parseInt(await trigger(\"atto::prompt\", [\"Count (1-48):\"]));
|
|
|
|
if (!count) {
|
|
return;
|
|
}
|
|
|
|
document.getElementById(\"invite_codes_output\").classList.remove(\"hidden\");
|
|
document.getElementById(\"invite_codes_error_output\").classList.remove(\"hidden\");
|
|
document.getElementById(\"invite_codes_output\").children[0].innerText = \"Working... expect to wait 50ms per invite code\";
|
|
|
|
fetch(`/api/v1/invites/${count}`, {
|
|
method: \"POST\",
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
|
|
if (res.ok) {
|
|
document.getElementById(\"invite_codes_output\").children[0].innerText = res.payload[0];
|
|
document.getElementById(\"invite_codes_error_output\").children[0].innerText = res.payload[1];
|
|
}
|
|
});
|
|
};"))))))
|
|
(text "{%- endif %}")
|
|
|
|
(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 %}")
|
|
(text "{{ components::become_supporter_button() }}")
|
|
(text "{%- endif %}")))
|
|
|
|
(text "{% if user.was_purchased and user.invite_code == 0 -%}")
|
|
(form
|
|
("class" "card w-full lowered flex flex-col gap-2")
|
|
("onsubmit" "update_invite_code(event)")
|
|
(p (text "Your account is currently activated without an invite code. If you stop paying for supporter, your account will be locked again until you renew. You can provide an invite code to avoid this if you're planning on cancelling."))
|
|
|
|
(div
|
|
("class" "flex flex-col gap-1")
|
|
(label
|
|
("for" "invite_code")
|
|
(b
|
|
(text "Invite code")))
|
|
(input
|
|
("type" "text")
|
|
("placeholder" "invite code")
|
|
("name" "invite_code")
|
|
("required" "")
|
|
("id" "invite_code")))
|
|
|
|
(button
|
|
(text "Submit")))
|
|
(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."))))
|
|
(div
|
|
("class" "card-nest")
|
|
("ui_ident" "default_profile_page")
|
|
(div
|
|
("class" "card small")
|
|
(b
|
|
(text "Default profile tab")))
|
|
(div
|
|
("class" "card")
|
|
(select
|
|
("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_profile_tab', event.target.selectedOptions[0].value)")
|
|
(option
|
|
("value" "Posts")
|
|
("selected" "{% if profile.settings.default_profile_tab == 'Posts' -%}true{% else %}false{%- endif %}")
|
|
(text "Posts"))
|
|
(option
|
|
("value" "Responses")
|
|
("selected" "{% if profile.settings.default_profile_tab == 'Responses' -%}true{% else %}false{%- endif %}")
|
|
(text "Responses")))
|
|
(span
|
|
("class" "fade")
|
|
(text "This represents the timeline that is shown on your profile by default.")))))
|
|
(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(async () => {
|
|
const ui = await 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 user.permissions|has_supporter %}
|
|
alert(\"Please cancel your membership before deleting your account. You'll have to wait until the next cycle to delete your account after, or you can request support if it is urgent.\");
|
|
return;
|
|
// {% endif %}
|
|
|
|
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();
|
|
});
|
|
};
|
|
|
|
globalThis.update_invite_code = async (e) => {
|
|
e.preventDefault();
|
|
await trigger(\"atto::debounce\", [\"invite_codes::try\"]);
|
|
fetch(\"/api/v1/auth/user/me/invite_code\", {
|
|
method: \"POST\",
|
|
headers: {
|
|
\"Content-Type\": \"application/json\",
|
|
},
|
|
body: JSON.stringify({
|
|
invite_code: e.target.invite_code.value,
|
|
}),
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
|
|
if (res.ok) {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
}
|
|
|
|
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\",
|
|
\"default_profile_page\",
|
|
]);
|
|
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\",
|
|
],
|
|
[
|
|
[\"private_biography\", \"Private biography\"],
|
|
settings.private_biography,
|
|
\"textarea\",
|
|
{
|
|
embed_html:
|
|
'<span class=\"fade\">This biography is only shown to users you are not following while your account is private.</span>',
|
|
},
|
|
],
|
|
[[\"status\", \"Status\"], settings.status, \"textarea\"],
|
|
[
|
|
[\"warning\", \"Profile warning\"],
|
|
settings.warning,
|
|
\"textarea\",
|
|
],
|
|
[[\"muted\", \"Muted phrases\"], settings.muted.join(\"\\n\"), \"textarea\", {
|
|
embed_html:
|
|
'<span class=\"fade\">Muted phrases should all be on new lines.</span>',
|
|
}],
|
|
[[], \"Accessibility\", \"title\"],
|
|
[
|
|
[\"large_text\", \"Increase UI text size\"],
|
|
\"{{ profile.settings.large_text }}\",
|
|
\"checkbox\",
|
|
],
|
|
[
|
|
[\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"],
|
|
\"{{ profile.settings.paged_timelines }}\",
|
|
\"checkbox\",
|
|
],
|
|
[
|
|
[\"auto_clear_notifs\", \"Automatically clear all notifications when you open the notifications page\"],
|
|
\"{{ profile.settings.auto_clear_notifs }}\",
|
|
\"checkbox\",
|
|
],
|
|
],
|
|
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\",
|
|
],
|
|
[
|
|
[\"auto_unlist\", \"Automatically mark my posts as NSFW\"],
|
|
\"{{ profile.settings.auto_unlist }}\",
|
|
\"checkbox\",
|
|
],
|
|
[
|
|
[\"auto_full_unlist\", \"Only publish my posts to my profile\"],
|
|
\"{{ profile.settings.auto_full_unlist }}\",
|
|
\"checkbox\",
|
|
],
|
|
[
|
|
[],
|
|
\"Your posts will still be visible in the \\\"Following\\\" timeline for users following you.\",
|
|
\"text\",
|
|
],
|
|
[
|
|
[\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'],
|
|
\"{{ profile.settings.all_timeline_hide_answers }}\",
|
|
\"checkbox\",
|
|
],
|
|
[
|
|
[
|
|
\"hide_associated_blocked_users\",
|
|
\"Hide users that you've blocked on your other accounts from timelines\",
|
|
],
|
|
\"{{ profile.settings.hide_associated_blocked_users }}\",
|
|
\"checkbox\",
|
|
],
|
|
[
|
|
[
|
|
\"hide_from_social_lists\",
|
|
\"Hide my profile from social lists (followers/following)\",
|
|
],
|
|
\"{{ profile.settings.hide_from_social_lists }}\",
|
|
\"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\",
|
|
],
|
|
[
|
|
[
|
|
\"enable_drawings\",
|
|
\"Allow users to create drawings and submit them with questions\",
|
|
],
|
|
\"{{ profile.settings.enable_drawings }}\",
|
|
\"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\",
|
|
],
|
|
[[], \"Fun\", \"title\"],
|
|
[
|
|
[\"disable_gpa_fun\", \"Disable GPA\"],
|
|
\"{{ profile.settings.disable_gpa_fun }}\",
|
|
\"checkbox\",
|
|
],
|
|
[
|
|
[\"disable_achievements\", \"Disable achievements\"],
|
|
\"{{ profile.settings.disable_achievements }}\",
|
|
\"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.\",
|
|
},
|
|
],
|
|
// online indicator
|
|
[[], \"\", \"divider\"],
|
|
[
|
|
[\"theme_color_online\", \"Online indicator (online)\"],
|
|
\"{{ profile.settings.theme_color_online }}\",
|
|
\"color\",
|
|
{
|
|
description:
|
|
\"The green dot next to the name of online users.\",
|
|
},
|
|
],
|
|
[
|
|
[\"theme_color_idle\", \"Online indicator (idle)\"],
|
|
\"{{ profile.settings.theme_color_idle }}\",
|
|
\"color\",
|
|
{
|
|
description:
|
|
\"The yellow dot next to the name of online users.\",
|
|
},
|
|
],
|
|
[
|
|
[\"theme_color_offline\", \"Online indicator (offline)\"],
|
|
\"{{ profile.settings.theme_color_offline }}\",
|
|
\"color\",
|
|
{
|
|
description:
|
|
\"The grey next to the name of online users.\",
|
|
},
|
|
],
|
|
];
|
|
|
|
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:
|
|
'<span class=\"fade\">Custom CSS input embedded into your theme.</span>',
|
|
},
|
|
]);
|
|
}
|
|
|
|
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 %}")
|