diff --git a/README.md b/README.md index 38eaa9e..71fcd3d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ cd ../tetratto Your first start of Tetratto might be a little slow as it's going to download all icon SVGs required for the HTML templates to render properly. These icons will be stored on disk, so there's no need to worry about this time _every_ restart. +Tetratto attempts to load `/public/fonts/lexend_variable.woff2` by defualt. This font is not included in the source by default, so you must download it yourself. Download Lexend (available from Google Fonts) as a variable font, and then create a `fonts` directory in the created `public` directory (relative to your configuration file). Place the font file (named "lexend_variable.woff2") in this fonts directory. + +Please note that Google Fonts only distributes Lexend Variable as a TTF file. You can use [`woff2_convert`](https://github.com/google/woff2) to convert the TTF into a woff2 file (`woff2_convert lexend_variable.ttf`). + ## Configuration In the directory you're running Tetratto from, you should create a `tetratto.toml` file. This file follows the configuration schema defined [here](https://trisuaso.github.io/tetratto/tetratto/config/struct.Config.html)! diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index a464014..955960c 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -80,6 +80,7 @@ version = "1.0.0" "auth:label.relationship" = "Relationship" "auth:label.joined_communities" = "Joined communities" "auth:label.recent_posts" = "Recent posts" +"auth:label.recent_answers" = "Recent answers" "auth:label.recent_with_tag" = "Recent posts (with tag)" "auth:label.recent_replies" = "Recent replies" "auth:label.recent_posts_with_media" = "Recent posts (with media)" @@ -176,7 +177,7 @@ version = "1.0.0" "settings:tab.profile" = "Profile" "settings:tab.theme" = "Theme" "settings:tab.sessions" = "Sessions" -"settings:tab.connections" = "Connections" +"settings:tab.grants" = "Grants" "settings:tab.images" = "Images" "settings:tab.presets" = "Presets" "settings:label.change_password" = "Change password" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 1a53a2e..6d29ae1 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -85,13 +85,18 @@ box-sizing: border-box; } +@font-face { + font-family: "Lexend"; + src: url("/public/fonts/lexend_variable.woff2") format("woff2"); +} + html, body { line-height: 1.5; letter-spacing: 0.15px; font-family: - "Inter", "Poppins", "Roboto", ui-sans-serif, system-ui, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Lexend", "Inter", "Poppins", "Roboto", ui-sans-serif, system-ui, + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; color: var(--color-text); background: var(--color-surface); diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 71a9a5c..bec7d76 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -466,7 +466,7 @@ button.camo:hover, input, textarea, select { - padding: 0.35rem var(--pad-3); + padding: var(--pad-2) var(--pad-3); border-radius: var(--radius); outline: none; transition: background 0.15s; @@ -481,6 +481,10 @@ select { color: var(--color-text-lowered); } +input { + height: 32px; +} + textarea { min-height: 5rem; } @@ -564,10 +568,27 @@ input[type="checkbox"]:checked { background-image: url("/icons/check.svg"); } +input[type="file"] { + height: max-content; +} + label { cursor: pointer; } +.round_form input, +.round_form .square { + border-radius: var(--circle); +} + +.round_form input { + padding: var(--pad-2) var(--pad-4); +} + +.round_form input[type="file"] { + padding: 0 var(--pad-4); +} + /* pillmenu */ .pillmenu { display: flex; @@ -874,12 +895,12 @@ nav .button:not(.title):not(.active):hover { display: none; } -.mobile_nav .pillmenu a:first-of-type { +.mobile_nav .pillmenu a:not(.dropdown *):first-of-type { border-top-left-radius: var(--radius) !important; border-bottom-left-radius: var(--radius) !important; } -.mobile_nav .pillmenu a:last-of-type { +.mobile_nav .pillmenu a:not(.dropdown *):last-of-type { border-top-right-radius: var(--radius) !important; border-bottom-right-radius: var(--radius) !important; } @@ -1033,14 +1054,14 @@ dialog:is(.dark *)::backdrop { } .dropdown .inner .active::after { - top: 0; - left: 0; + top: 10%; + left: 5px; width: 5px; content: ""; - height: 100%; + height: 80%; position: absolute; background: var(--color-primary); - border-radius: var(--radius); + border-radius: var(--circle); } .dropdown:not(nav *):has(.inner.open) button:not(.inner button) { @@ -1085,7 +1106,7 @@ dialog:is(.dark *)::backdrop { width: max-content; max-width: calc(100dvw - var(--pad-4)); border-radius: var(--radius); - padding: var(--pad-3) var(--pad-4); + padding: var(--pad-2) var(--pad-3); animation: popin ease-in-out 1 0.15s running; display: flex; justify-content: space-between; @@ -1502,3 +1523,47 @@ details.accordion .inner { top: 0; border-radius: var(--radius); } + +/* menus */ +menu { + display: flex; +} + +menu a { + justify-content: flex-start; + width: 100%; + text-decoration: none !important; + background: var(--color-raised); + color: var(--color-text-raised); + padding: var(--pad-2) var(--pad-3); + font-weight: 500; + display: flex; + align-items: center; + gap: var(--pad-2); +} + +menu a:hover { + background: var(--color-super-raised); +} + +menu a.active { + background: var(--color-primary); + color: var(--color-text-primary); +} + +menu.col { + flex-direction: column; + width: 25rem; + max-width: 100%; + padding: var(--pad-3) 0; +} + +menu a:first-child { + border-top-left-radius: var(--radius); + border-top-right-radius: var(--radius); +} + +menu a:last-child { + border-bottom-left-radius: var(--radius); + border-bottom-right-radius: var(--radius); +} diff --git a/crates/app/src/public/html/auth/connection.lisp b/crates/app/src/public/html/auth/connection.lisp index 0f95fe9..12a6b6f 100644 --- a/crates/app/src/public/html/auth/connection.lisp +++ b/crates/app/src/public/html/auth/connection.lisp @@ -45,7 +45,7 @@ `${message}. You can now close this tab.`; setTimeout(() => { - window.location.href = \"/settings#/connections\"; + window.location.href = \"/settings#/grants\"; }, 500); }, 1000);")) @@ -75,7 +75,7 @@ `${message}. You can now close this tab.`; setTimeout(() => { - window.location.href = \"/settings#/connections\"; + window.location.href = \"/settings#/grants\"; }, 500); }, 1000);")) diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index 622b569..aec080a 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -188,7 +188,7 @@ (b (text "{{ text \"settings:label.change_avatar\" }}"))) (form - ("class" "card flex gap_2 flex_row flex_wrap items_center") + ("class" "card big_icon flex gap_2 flex_row flex_wrap items_center") ("method" "post") ("enctype" "multipart/form-data") ("onsubmit" "upload_avatar(event)") @@ -199,6 +199,7 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w_content")) (button + ("class" "small square big_icon") (text "{{ icon \"check\" }}")))) (div ("class" "card_nest") @@ -208,7 +209,7 @@ (b (text "{{ text \"settings:label.change_banner\" }}"))) (form - ("class" "card flex flex_col gap_2") + ("class" "card big_icon flex flex_col gap_2") ("method" "post") ("enctype" "multipart/form-data") ("onsubmit" "upload_banner(event)") @@ -221,6 +222,7 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp") ("class" "w_content")) (button + ("class" "small square big_icon") (text "{{ icon \"check\" }}"))) (span ("class" "fade") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 98ad355..988882d 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -18,17 +18,7 @@ (span ("class" "desktop") (str (text "general:link.home")))) - - (text "{% if user -%}") - (a - ("href" "/communities") - ("class" "button {% if selected == 'communities' -%}active{%- endif %}") - (icon (text "book-heart")) - (span - ("class" "desktop") - (str (text "general:link.communities")))) - - (text "{%- endif %} {%- endif %}")) + (text "{%- endif %}")) (div ("class" "flex nav_side") @@ -71,6 +61,10 @@ (div ("class" "inner") + (a + ("href" "/communities") + (icon (text "book-heart")) + (str (text "general:link.communities"))) (a ("href" "/chats/0/0") (icon (text "message-circle")) @@ -385,9 +379,9 @@ (span (text "{{ text \"settings:tab.sessions\" }}"))) (a - ("data-tab-button" "connections") - ("href" "#/connections") + ("data-tab-button" "grants") + ("href" "#/grants") (text "{{ icon \"cable\" }}") (span - (text "{{ text \"settings:tab.connections\" }}"))) + (text "{{ text \"settings:tab.grants\" }}"))) (text "{%- endmacro %}") diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index f5b6cda..7e1ee28 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -20,13 +20,36 @@ (b (text "{{ tag }}"))) (text "{%- endif %}")) - (text "{% if user -%}") - (a - ("href" "/search?profile={{ profile.id }}") - ("class" "button lowered small") - (text "{{ icon \"search\" }}") - (span - (text "{{ text \"general:link.search\" }}"))) + (text "{% if not tag -%}") + (div + ("class" "flex gap_2") + (div + ("class" "dropdown") + (button + ("class" "lowered small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "arrow-down-up")) + (text "{{ order }}")) + (div + ("class" "inner") + (a + ("href" "?o=Recent&f=true") + (icon (text "calendar-arrow-down")) + (span (text "Recent"))) + (a + ("href" "?o=Popular&f=true") + (icon (text "trending-up")) + (span (text "Popular"))))) + + (text "{% if user -%}") + (a + ("href" "/search?profile={{ profile.id }}") + ("class" "button lowered small") + (text "{{ icon \"search\" }}") + (span + (text "{{ text \"general:link.search\" }}"))) + (text "{%- endif %}")) (text "{%- endif %}")) (div ("class" "card w_full flex flex_col gap_2") @@ -42,7 +65,7 @@ (text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(async () => { - await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&order={{ order }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); (await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true; console.log(\"created profile timeline\"); }, 1000);")) diff --git a/crates/app/src/public/html/profile/responses.lisp b/crates/app/src/public/html/profile/responses.lisp index 925d271..86588a1 100644 --- a/crates/app/src/public/html/profile/responses.lisp +++ b/crates/app/src/public/html/profile/responses.lisp @@ -13,7 +13,7 @@ ("class" "flex gap_2 items_center") (text "{% if not tag -%} {{ icon \"clock\" }}") (span - (text "{{ text \"auth:label.recent_posts\" }}")) + (text "{{ text \"auth:label.recent_answers\" }}")) (text "{% else %} {{ icon \"tag\" }}") (span (text "{{ text \"auth:label.recent_with_tag\" }}: ") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 8f69ff3..b04c64e 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -2,415 +2,319 @@ (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() }}")))) - +(article + ("class" "flex flex_row gap_2 content_container") ; nav desktop - (div - ("class" "desktop pillmenu") + (menu + ("class" "desktop col") (text "{{ macros::profile_settings_nav_options() }}")) - ; ... - (div - ("class" "w_full flex flex_col gap_2 hidden") - ("data-tab" "presets") + ; content + (main + ("class" "flex flex_col gap_2 w_full") + (text "{% if profile.id != user.id -%}") (div - ("class" "card lowered flex flex_col gap_2") - (a - ("href" "#/account") - ("class" "button secondary") - (icon (text "arrow-left")) - (span - (str (text "general:action.back")))) - (div - ("class" "card_nest") - (div - ("class" "card flex items_center gap_2 small") - (icon (text "cooking-pot")) - (span - (str (text "settings:tab.presets")))) - (div - ("class" "card flex flex_col gap_2 secondary") - (p (text "Not sure where to start? Try some settings presets!")) - (details - ("class" "w_full accordion") - (summary - (icon (text "rss")) - (text "Microblogging")) + ("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" "inner flex flex_col gap_2") - (p ("class" "fade") (text "Focus on yourself and your communities.")) - (ul ("id" "preset_microblogging_ul")) - (button - ("onclick" "apply_preset(PRESET_MICROBLOGGING)") - (icon (text "settings")) - (str (text "general:action.apply"))))) - - (details - ("class" "w_full accordion") - (summary - (icon (text "message-circle-heart")) - (text "Q&A")) - - (div - ("class" "inner flex flex_col gap_2") - (p ("class" "fade") (text "Just like Neospring!")) - (ul ("id" "preset_questions_ul")) - (button - ("onclick" "apply_preset(PRESET_QUESTIONS)") - (icon (text "settings")) - (str (text "general:action.apply"))))) - - (details - ("class" "w_full accordion") - (summary - (icon (text "key")) - (text "Private")) - - (div - ("class" "inner flex flex_col gap_2") - (p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following.")) - (ul ("id" "preset_private_ul")) - (button - ("onclick" "apply_preset(PRESET_PRIVATE)") - (icon (text "settings")) - (str (text "general:action.apply"))))) - - (details - ("class" "w_full accordion") - (summary - (icon (text "eye-closed")) - (text "NSFW")) - - (div - ("class" "inner flex flex_col gap_2") - (p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly.")) - (ul ("id" "preset_nsfw_ul")) - (button - ("onclick" "apply_preset(PRESET_NSFW)") - (icon (text "settings")) - (str (text "general:action.apply"))))))))) - - (div - ("class" "w_full flex flex_col gap_2") - ("data-tab" "account") + ; nav (div - ("class" "card lowered flex flex_col gap_2") - ("id" "account_settings") + ("class" "mobile_nav mobile") + ; primary nav (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/followers") - ("href" "#/account/followers") - (text "{{ icon \"rss\" }}") - (span - (text "{{ text \"auth:label.followers\" }}"))) - (a - ("data-tab-button" "account/blocks") - ("href" "#/account/blocks") - (text "{{ icon \"shield\" }}") - (span - (text "{{ text \"settings:tab.blocks\" }}")))) + ("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() }}")))) - (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 "") - (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 - (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") - (icon (text "skull")) - (b (str (text "communities:label.danger_zone")))) + ("class" "w_full flex flex_col gap_2 hidden") + ("data-tab" "presets") (div ("class" "card lowered flex flex_col gap_2") - (details - ("class" "accordion") - (summary - ("class" "flex items_center gap_2") - (icon_class (text "chevron-down") (text "dropdown_arrow")) - (str (text "settings:label.deactivate_account"))) + (a + ("href" "#/account") + ("class" "button secondary") + (icon (text "arrow-left")) + (span + (str (text "general:action.back")))) + (div + ("class" "card_nest") (div - ("class" "inner flex flex_col gap_2") - (p (text "Deactivating your account will treat it as deleted, but all your data will be recoverable if you change your mind. This option is recommended over a full deletion.")) + ("class" "card flex items_center gap_2 small") + (icon (text "cooking-pot")) + (span + (str (text "settings:tab.presets")))) + (div + ("class" "card flex flex_col gap_2 secondary") + (p (text "Not sure where to start? Try some settings presets!")) + (details + ("class" "w_full accordion") + (summary + (icon (text "rss")) + (text "Microblogging")) + + (div + ("class" "inner flex flex_col gap_2") + (p ("class" "fade") (text "Focus on yourself and your communities.")) + (ul ("id" "preset_microblogging_ul")) + (button + ("onclick" "apply_preset(PRESET_MICROBLOGGING)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w_full accordion") + (summary + (icon (text "message-circle-heart")) + (text "Q&A")) + + (div + ("class" "inner flex flex_col gap_2") + (p ("class" "fade") (text "Just like Neospring!")) + (ul ("id" "preset_questions_ul")) + (button + ("onclick" "apply_preset(PRESET_QUESTIONS)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w_full accordion") + (summary + (icon (text "key")) + (text "Private")) + + (div + ("class" "inner flex flex_col gap_2") + (p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following.")) + (ul ("id" "preset_private_ul")) + (button + ("onclick" "apply_preset(PRESET_PRIVATE)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w_full accordion") + (summary + (icon (text "eye-closed")) + (text "NSFW")) + + (div + ("class" "inner flex flex_col gap_2") + (p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly.")) + (ul ("id" "preset_nsfw_ul")) + (button + ("onclick" "apply_preset(PRESET_NSFW)") + (icon (text "settings")) + (str (text "general:action.apply"))))))))) + + (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") + ("style" "z-index: 0") + (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/followers") + ("href" "#/account/followers") + (text "{{ icon \"rss\" }}") + (span + (text "{{ text \"auth:label.followers\" }}"))) + (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 "") + (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 - ("onclick" "deactivate_account()") - (icon (text "lock")) - (span - (str (text "settings:label.deactivate")))))) - (details - ("class" "accordion") - (summary - ("class" "flex items_center gap_2") - (icon_class (text "chevron-down") (text "dropdown_arrow")) - (str (text "settings:label.delete_account"))) + ("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" "inner flex flex_col gap_2") - ("onsubmit" "delete_account(event)") + ("class" "card flex flex_col gap_2") + ("onsubmit" "change_username(event)") (div ("class" "flex flex_col gap_1") (label - ("for" "current_password") - (text "{{ text \"settings:label.current_password\" }}")) + ("for" "new_username") + (text "{{ text \"settings:label.new_username\" }}")) (input - ("type" "password") - ("name" "current_password") - ("id" "current_password") - ("placeholder" "current_password") + ("type" "text") + ("name" "new_username") + ("id" "new_username") + ("placeholder" "new_username") ("required" "") - ("minlength" "6") - ("autocomplete" "off"))) + ("minlength" "2"))) (button - (text "{{ icon \"trash\" }}") + (text "{{ icon \"check\" }}") (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\" }}"))) + (text "{{ text \"general:action.save\" }}")))))) (div ("class" "card_nest") + ("ui_ident" "delete_account") (div - ("class" "card flex items_center gap_2 small") - (text "{{ icon \"user-lock\" }}") - (span - (text "{{ text \"settings:tab.security\" }}"))) + ("class" "card small flex items_center gap_2 red") + (icon (text "skull")) + (b (str (text "communities:label.danger_zone")))) (div - ("class" "card flex flex_col gap_2 secondary") - (div - ("class" "card_nest") - ("ui_ident" "two_factor_authentication") + ("class" "card lowered flex flex_col gap_2") + (details + ("class" "accordion") + (summary + ("class" "flex items_center gap_2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.deactivate_account"))) (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"))) + ("class" "inner flex flex_col gap_2") + (p (text "Deactivating your account will treat it as deleted, but all your data will be recoverable if you change your mind. This option is recommended over a full deletion.")) (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\" }}"))) + ("onclick" "deactivate_account()") + (icon (text "lock")) + (span + (str (text "settings:label.deactivate")))))) + (details + ("class" "accordion") + (summary + ("class" "flex items_center gap_2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.delete_account"))) (form - ("class" "card flex flex_col gap_2") - ("onsubmit" "change_password(event)") + ("class" "inner flex flex_col gap_2") + ("onsubmit" "delete_account(event)") (div ("class" "flex flex_col gap_1") (label @@ -424,1880 +328,1981 @@ ("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 - (text "{{ icon \"check\" }}") + (text "{{ icon \"trash\" }}") (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\" }}") + (text "{{ text \"general:action.delete\" }}"))))))) + (button + ("onclick" "save_settings()") + ("id" "save_button") + (text "{{ icon \"check\" }}") (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(id=user.id) }} {{ 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 %} {{ components::pagination(page=page, items=following|length, key=\"#/account/following\") }}")))) - (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/followers") + (text "{{ text \"general:action.save\" }}")))) (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\" }}"))) + ("class" "w_full flex flex_col gap_2 hidden") + ("data-tab" "account/security") (div - ("class" "card_nest") - (div - ("class" "card flex items_center gap_2 small") - (text "{{ icon \"rss\" }}") + ("class" "card lowered flex flex_col gap_2") + (a + ("href" "#/account") + ("class" "button secondary") + (text "{{ icon \"arrow-left\" }}") (span - (text "{{ text \"auth:label.followers\" }}"))) - (div - ("class" "card flex flex_col gap_2") - (text "{% for userfollow in followers %} {% 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(id=user.id) }} {{ components::full_username(user=user) }}")) - (div - ("class" "flex gap_2") - (button - ("class" "lowered red small") - ("onclick" "force_unfollow_me('{{ user.id }}')") - (text "{{ icon \"user-minus\" }}") - (span - (str (text "stacks:label.remove")))) - (a - ("href" "/@{{ user.username }}") - ("class" "button lowered small") - (text "{{ icon \"external-link\" }}") - (span - (text "{{ text \"requests:action.view_profile\" }}"))))) - (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/followers\") }}")))) - (script - (text "globalThis.force_unfollow_me = async (uid) => { - await trigger(\"atto::debounce\", [\"users::follow\"]); - - fetch(`/api/v1/auth/user/${uid}/force_unfollow_me`, { - 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(id=user.id) }} {{ 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 %}") - (details - ("class" "accordion w_full") - (summary - ("class" "card flex flex_wrap gap_2 items_center justify_between") - (div - ("class" "flex gap_2 items_center") - (icon_class (text "chevron-down") (text "dropdown_arrow")) - (b - (span - ("class" "date") - (text "{{ upload.created }}")) - (text " ({{ upload.metadata.what }})"))) - (div - ("class" "flex gap_2") - (button - ("class" "raised small") - ("onclick" "trigger('ui::lightbox_open', ['{{ config.service_hosts.buckets }}/{{ upload.bucket }}/{{ upload.id }}'])") - (text "{{ icon \"view\" }}") - (span - (text "{{ text \"general:action.view\" }}"))) - (button - ("class" "raised small red") - ("onclick" "remove_upload('{{ upload.bucket }}', '{{ upload.id }}')") - (text "{{ icon \"x\" }}") - (span - (text "{{ text \"stacks:label.remove\" }}"))))) - - (div - ("class" "inner flex flex_col gap_2") - (form - ("class" "card lowered flex flex_col gap_2") - ("onsubmit" "update_upload_alt(event, '{{ upload.id }}')") - (div - ("class" "flex flex_col gap_1") - (label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text")))) - (textarea - ("id" "alt_{{ upload.id }}") - ("name" "alt") - ("class" "w_full") - ("placeholder" "Alternative text") - (text "{{ upload.metadata.alt|safe }}"))) - - (button - (icon (text "check")) - (str (text "general:action.save")))))) - (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") - (script - (text "globalThis.remove_upload = async (bucket, id) => { - if ( - !(await trigger(\"atto::confirm\", [ - \"Are you sure you would like to do this? This action is permanent.\", - ])) - ) { - return; - } - - fetch(`/api/v1/uploads/${bucket}/${id}`, { - method: \"DELETE\", - }) - .then((res) => res.json()) - .then((res) => { - trigger(\"atto::toast\", [ - res.ok ? \"success\" : \"error\", - res.message, - ]); - }); - }; - - globalThis.update_upload_alt = async (e, id) => { - e.preventDefault(); - fetch(`/api/v1/uploads/${id}/alt`, { - method: \"POST\", - headers: { - \"Content-Type\": \"application/json\", - }, - body: JSON.stringify({ - alt: e.target.alt.value, - }), - }) - .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 -%}") - (text "{% if has_developer_pass or is_supporter -%}") - (div - ("class" "card_nest") - ("ui_ident" "supporter_card") - (div - ("class" "card small flex items_center gap_2") - (icon (text "credit-card")) - (b - (text "Manage billing"))) - (div - ("class" "card flex flex_col gap_2") - (p - (text "You currently have a subscription! You can manage your billing information below. ") - (b - (text "Please use your email address you supplied when paying to log into the billing portal.")) - (text " You can manage all of your active subscriptions through this page.")) - (a - ("href" "{{ config.stripe.billing_portal_url }}") - ("class" "button lowered") - ("target" "_blank") - (text "Manage billing")))) - (text "{%- endif %}") - - (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 no_p_margin") - (text "{% if is_supporter -%}") - (p - (text "You ") - (b (text "are ")) - (text "a supporter! Thank you for all that you do.")) - (text "{% else %}") - (text "{{ components::become_supporter_button() }}") - (text "{%- endif %}"))) - - (div - ("class" "card_nest") - ("ui_ident" "supporter_card") - (div - ("class" "card small flex items_center gap_2") - (icon (text "id-card-lanyard")) - (b - (text "Developer pass status"))) - (div - ("class" "card flex flex_col gap_2 no_p_margin") - (text "{% if has_developer_pass -%}") - (p - (text "You currently have a developer pass!")) - (text "{% else %}") - (text "{{ components::get_developer_pass_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 - (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 - (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.")))) - (div - ("class" "flex flex_col gap_2") - ("ui_ident" "show_presets") - (hr ("class" "margin")) + (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 + (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(id=user.id) }} {{ 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 %} {{ components::pagination(page=page, items=following|length, key=\"#/account/following\") }}")))) + (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/followers") + (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.followers\" }}"))) + (div + ("class" "card flex flex_col gap_2") + (text "{% for userfollow in followers %} {% 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(id=user.id) }} {{ components::full_username(user=user) }}")) + (div + ("class" "flex gap_2") + (button + ("class" "lowered red small") + ("onclick" "force_unfollow_me('{{ user.id }}')") + (text "{{ icon \"user-minus\" }}") + (span + (str (text "stacks:label.remove")))) + (a + ("href" "/@{{ user.username }}") + ("class" "button lowered small") + (text "{{ icon \"external-link\" }}") + (span + (text "{{ text \"requests:action.view_profile\" }}"))))) + (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/followers\") }}")))) + (script + (text "globalThis.force_unfollow_me = async (uid) => { + await trigger(\"atto::debounce\", [\"users::follow\"]); + + fetch(`/api/v1/auth/user/${uid}/force_unfollow_me`, { + 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(id=user.id) }} {{ 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 %}") + (details + ("class" "accordion w_full") + (summary + ("class" "card flex flex_wrap gap_2 items_center justify_between") + (div + ("class" "flex gap_2 items_center") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (b + (span + ("class" "date") + (text "{{ upload.created }}")) + (text " ({{ upload.metadata.what }})"))) + (div + ("class" "flex gap_2") + (button + ("class" "raised small") + ("onclick" "trigger('ui::lightbox_open', ['{{ config.service_hosts.buckets }}/{{ upload.bucket }}/{{ upload.id }}'])") + (text "{{ icon \"view\" }}") + (span + (text "{{ text \"general:action.view\" }}"))) + (button + ("class" "raised small red") + ("onclick" "remove_upload('{{ upload.bucket }}', '{{ upload.id }}')") + (text "{{ icon \"x\" }}") + (span + (text "{{ text \"stacks:label.remove\" }}"))))) + + (div + ("class" "inner flex flex_col gap_2") + (form + ("class" "card lowered flex flex_col gap_2") + ("onsubmit" "update_upload_alt(event, '{{ upload.id }}')") + (div + ("class" "flex flex_col gap_1") + (label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text")))) + (textarea + ("id" "alt_{{ upload.id }}") + ("name" "alt") + ("class" "w_full") + ("placeholder" "Alternative text") + (text "{{ upload.metadata.alt|safe }}"))) + + (button + (icon (text "check")) + (str (text "general:action.save")))))) + (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") + (script + (text "globalThis.remove_upload = async (bucket, id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This action is permanent.\", + ])) + ) { + return; + } + + fetch(`/api/v1/uploads/${bucket}/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.update_upload_alt = async (e, id) => { + e.preventDefault(); + fetch(`/api/v1/uploads/${id}/alt`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + alt: e.target.alt.value, + }), + }) + .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 -%}") + (text "{% if has_developer_pass or is_supporter -%}") + (div + ("class" "card_nest") + ("ui_ident" "supporter_card") + (div + ("class" "card small flex items_center gap_2") + (icon (text "credit-card")) + (b + (text "Manage billing"))) + (div + ("class" "card flex flex_col gap_2") + (p + (text "You currently have a subscription! You can manage your billing information below. ") + (b + (text "Please use your email address you supplied when paying to log into the billing portal.")) + (text " You can manage all of your active subscriptions through this page.")) + (a + ("href" "{{ config.stripe.billing_portal_url }}") + ("class" "button lowered") + ("target" "_blank") + (text "Manage billing")))) + (text "{%- endif %}") + + (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 no_p_margin") + (text "{% if is_supporter -%}") + (p + (text "You ") + (b (text "are ")) + (text "a supporter! Thank you for all that you do.")) + (text "{% else %}") + (text "{{ components::become_supporter_button() }}") + (text "{%- endif %}"))) + + (div + ("class" "card_nest") + ("ui_ident" "supporter_card") + (div + ("class" "card small flex items_center gap_2") + (icon (text "id-card-lanyard")) + (b + (text "Developer pass status"))) + (div + ("class" "card flex flex_col gap_2 no_p_margin") + (text "{% if has_developer_pass -%}") + (p + (text "You currently have a developer pass!")) + (text "{% else %}") + (text "{{ components::get_developer_pass_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 "Not sure what to do?"))) + (text "{{ text \"settings:label.change_avatar\" }}"))) + (form + ("class" "card round_form 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" "small square big_icon") + (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 no_p_margin") - (p - (text "Quickly set up your account with ") - (a ("href" "/settings#/presets") (text "settings presets")) - (text "!")))))) - (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] }} ") + ("class" "card small") (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 - ("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") + (text "{{ text \"settings:label.change_banner\" }}"))) + (form + ("class" "card round_form 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" "small square big_icon") + (text "{{ icon \"check\" }}"))) + (span + ("class" "fade") + (text "Use an image of 1100x350px for the best results.")))) (div - ("class" "card small") - (b - (text "Theme preference"))) + ("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.")))) (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."))))) - (text "{% if profile.applied_configurations|length > 0 -%}") - (div - ("class" "card_nest") - ("ui_ident" "applied_configurations") - (div - ("class" "card small flex items_center gap_2") - (icon (text "cog")) - (str (text "setttings:label.applied_configurations"))) - (div - ("class" "card") - (p (text "Products that you have purchased and applied to your profile are displayed below. Snippets are always synced to the product, meaning the owner could update it at any time.")) - (ul - (text "{% for cnf in profile.applied_configurations -%}") - (li - (text "{{ cnf[0] }} ") - (a - ("href" "/product/{{ cnf[1] }}") - (text "{{ cnf[1] }}"))) - (text "{%- endfor %}")))) - (text "{%- endif %}") - (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 %}") + ("class" "flex flex_col gap_2") + ("ui_ident" "show_presets") + (hr ("class" "margin")) + (div + ("class" "card_nest") + (div + ("class" "card small") + (b + (text "Not sure what to do?"))) + (div + ("class" "card no_p_margin") + (p + (text "Quickly set up your account with ") + (a ("href" "/settings#/presets") (text "settings presets")) + (text "!")))))) (button - ("class" "lowered") - ("onclick" "trigger('spotify::create_connection', ['{{ config.connections.spotify_client_id }}'])") - (text "{{ icon \"spotify\" }}") + ("onclick" "save_settings()") + ("id" "save_button") + (text "{{ icon \"check\" }}") (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 %}") + (text "{{ text \"general:action.save\" }}")))) (div - ("class" "card_nest") + ("class" "card w_full lowered hidden flex flex_col gap_2") + ("data-tab" "sessions") + (text "{% for token in profile.tokens %}") (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 -%}") + ("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 - (text "{{ value[0].data.name }}")) - (span - ("style" "display: contents;") - ("title" "Verified connection") - (text "{{ icon \"badge-check\" }}")) + ("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 - (text "{{ key }}")) + ("class" "fade") + (code + (text "{{ token[0] }}"))) + (text "{%- endif %}") (span - ("style" "display: contents;") - (text "{{ icon \"badge-alert\" }}")) - (text "{%- endif %}"))) - (div - ("class" "card flex flex_col gap_2") + ("class" "fade date") + (text "{{ token[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 %}") + ("class" "lowered red") + ("onclick" "remove_token('{{ token[1] }}')") + (text "{{ text \"general:action.delete\" }}"))) + (text "{% endfor %}")) (div - ("class" "card_nest") + ("class" "w_full hidden flex flex_col gap_2") + ("data-tab" "theme") (div - ("class" "card small flex items_center gap_4") + ("class" "card lowered flex flex_col gap_2") + ("id" "theme_settings") + (text "{% if failing_color_keys|length > 0 -%}") (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 }}")) - + ("class" "card flex flex_col gap_2") + ("style" "background: white; color: black") + ("ui_ident" "awful_contrast") (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 %}")))) - + ("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 + ("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."))))) + (text "{% if profile.applied_configurations|length > 0 -%}") + (div + ("class" "card_nest") + ("ui_ident" "applied_configurations") + (div + ("class" "card small flex items_center gap_2") + (icon (text "cog")) + (str (text "setttings:label.applied_configurations"))) + (div + ("class" "card") + (p (text "Products that you have purchased and applied to your profile are displayed below. Snippets are always synced to the product, meaning the owner could update it at any time.")) + (ul + (text "{% for cnf in profile.applied_configurations -%}") + (li + (text "{{ cnf[0] }} ") + (a + ("href" "/product/{{ cnf[1] }}") + (text "{{ cnf[1] }}"))) + (text "{%- endfor %}")))) + (text "{%- endif %}") + (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" "grants") + (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 red small") - ("onclick" "remove_grant('{{ grant[0].id }}')") - (text "{{ text \"general:action.delete\" }}")))) - (text "{% endfor %}") + ("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 }}")))) - (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 }}\"); + (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 }}")) - globalThis.remove_token = async (id) => { - if ( - !(await trigger(\"atto::confirm\", [ - \"Are you sure you would like to do this?\", - ])) - ) { - return; - } + (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 %}")))) - // reconstruct tokens (but without the token with the given id) - const new_tokens = []; + (button + ("class" "lowered red small") + ("onclick" "remove_grant('{{ grant[0].id }}')") + (text "{{ text \"general:action.delete\" }}")))) + (text "{% endfor %}") - for (const token of tokens) { - if (token[1] === id) { - continue; + (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; } - new_tokens.push(token); - } + // reconstruct tokens (but without the token with the given id) + const new_tokens = []; - 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(); + for (const token of tokens) { + if (token[1] === id) { + continue; } - }); - } - globalThis.deactivate_account = async () => { - if ( - !(await trigger(\"atto::confirm\", [ - \"Are you sure you want to do this?\", - ])) - ) { + 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(); + } + }); } - fetch(\"/api/v1/auth/user/{{ profile.id }}/deactivate\", { - method: \"POST\", - headers: { - \"Content-Type\": \"application/json\", - }, - body: JSON.stringify({ is_deactivated: true }), - }) - .then((res) => res.json()) - .then((res) => { - trigger(\"atto::toast\", [ - res.ok ? \"success\" : \"error\", - res.message, - ]); - }); - }; + globalThis.deactivate_account = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } - // presets - globalThis.apply_preset = async (preset) => { - if ( - !(await trigger(\"atto::confirm\", [ - \"Are you sure you would like to do this? This will change all listed settings to their listed values.\", - ])) - ) { - return; + fetch(\"/api/v1/auth/user/{{ profile.id }}/deactivate\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ is_deactivated: true }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + // presets + globalThis.apply_preset = async (preset) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This will change all listed settings to their listed values.\", + ])) + ) { + return; + } + + for (const x of preset) { + window.SETTING_SET_FUNCTIONS[0](x[0], x[1]) + } + + save_settings(); } - for (const x of preset) { - window.SETTING_SET_FUNCTIONS[0](x[0], x[1]) + globalThis.render_preset_lis = (preset, id) => { + for (const x of preset) { + document.getElementById(id).innerHTML += `