From 4735832cefd4c14c4d456feee2be70cb9512099b Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 30 Aug 2025 19:30:54 -0400 Subject: [PATCH] add: better user settings page --- README.md | 4 + crates/app/src/langs/en-US.toml | 3 +- crates/app/src/public/css/root.css | 9 +- crates/app/src/public/css/style.css | 81 +- .../app/src/public/html/auth/connection.lisp | 4 +- .../src/public/html/communities/settings.lisp | 6 +- crates/app/src/public/html/macros.lisp | 22 +- crates/app/src/public/html/profile/posts.lisp | 39 +- .../src/public/html/profile/responses.lisp | 2 +- .../app/src/public/html/profile/settings.lisp | 4381 +++++++++-------- .../app/src/public/html/timelines/search.lisp | 3 +- crates/app/src/routes/api/v1/stacks.rs | 23 +- crates/app/src/routes/pages/misc.rs | 26 +- crates/app/src/routes/pages/mod.rs | 4 +- crates/app/src/routes/pages/profile.rs | 1 + crates/core/src/database/posts.rs | 31 + 16 files changed, 2398 insertions(+), 2241 deletions(-) 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 += `
  • ${x[0]}: ${x[1]}
  • `; + } } - save_settings(); - } + globalThis.PRESET_MICROBLOGGING = [ + [\"default_timeline\", \"All\"], + [\"all_timeline_hide_answers\", true], + ]; - globalThis.render_preset_lis = (preset, id) => { - for (const x of preset) { - document.getElementById(id).innerHTML += `
  • ${x[0]}: ${x[1]}
  • `; - } - } + globalThis.PRESET_QUESTIONS = [ + [\"default_timeline\", \"Following\"], + [\"auto_full_unlist\", true], + [\"enable_questions\", true], + [\"allow_anonymous_questions\", true], + [\"enable_drawings\", true], + [\"hide_extra_post_tabs\", true], + ]; - globalThis.PRESET_MICROBLOGGING = [ - [\"default_timeline\", \"All\"], - [\"all_timeline_hide_answers\", true], - ]; + globalThis.PRESET_PRIVATE = [ + [\"private_profile\", true], + [\"private_last_seen\", true], + [\"private_communities\", true], + [\"private_chats\", true], + [\"private_mails\", true], + [\"require_account\", true], + ]; - globalThis.PRESET_QUESTIONS = [ - [\"default_timeline\", \"Following\"], - [\"auto_full_unlist\", true], - [\"enable_questions\", true], - [\"allow_anonymous_questions\", true], - [\"enable_drawings\", true], - [\"hide_extra_post_tabs\", true], - ]; + globalThis.PRESET_NSFW = [ + [\"auto_unlist\", true], + [\"show_nsfw\", true], + ]; - globalThis.PRESET_PRIVATE = [ - [\"private_profile\", true], - [\"private_last_seen\", true], - [\"private_communities\", true], - [\"private_chats\", true], - [\"private_mails\", true], - [\"require_account\", true], - ]; + render_preset_lis(PRESET_MICROBLOGGING, \"preset_microblogging_ul\"); + render_preset_lis(PRESET_QUESTIONS, \"preset_questions_ul\"); + render_preset_lis(PRESET_PRIVATE, \"preset_private_ul\"); + render_preset_lis(PRESET_NSFW, \"preset_nsfw_ul\"); - globalThis.PRESET_NSFW = [ - [\"auto_unlist\", true], - [\"show_nsfw\", true], - ]; + // ... + const account_settings = + document.getElementById(\"account_settings\"); + const profile_settings = + document.getElementById(\"profile_settings\"); + const theme_settings = document.getElementById(\"theme_settings\"); - render_preset_lis(PRESET_MICROBLOGGING, \"preset_microblogging_ul\"); - render_preset_lis(PRESET_QUESTIONS, \"preset_questions_ul\"); - render_preset_lis(PRESET_PRIVATE, \"preset_private_ul\"); - render_preset_lis(PRESET_NSFW, \"preset_nsfw_ul\"); + ui.refresh_container(account_settings, [ + \"supporter_ad\", + \"account_settings_tabs\", + \"home_timeline\", + \"notifications\", + \"change_username\", + \"delete_account\", + ]); + ui.refresh_container(profile_settings, [ + \"supporter_ad\", + \"change_avatar\", + \"change_banner\", + \"default_profile_page\", + \"show_presets\", + ]); + ui.refresh_container(theme_settings, [ + \"supporter_ad\", + \"awful_contrast\", + \"import_export\", + \"theme_preference\", + \"profile_theme\", + \"applied_configurations\", + ]); - // ... - const account_settings = - document.getElementById(\"account_settings\"); - const profile_settings = - document.getElementById(\"profile_settings\"); - const theme_settings = document.getElementById(\"theme_settings\"); - - ui.refresh_container(account_settings, [ - \"supporter_ad\", - \"account_settings_tabs\", - \"home_timeline\", - \"notifications\", - \"change_username\", - \"delete_account\", - ]); - ui.refresh_container(profile_settings, [ - \"supporter_ad\", - \"change_avatar\", - \"change_banner\", - \"default_profile_page\", - \"show_presets\", - ]); - ui.refresh_container(theme_settings, [ - \"supporter_ad\", - \"awful_contrast\", - \"import_export\", - \"theme_preference\", - \"profile_theme\", - \"applied_configurations\", - ]); - - ui.generate_settings_ui( - account_settings, - [ + ui.generate_settings_ui( + account_settings, [ - [\"display_name\", \"Display name\"], - \"{{ profile.settings.display_name }}\", + [ + [\"display_name\", \"Display name\"], + \"{{ profile.settings.display_name }}\", + \"input\", + ], + [ + [\"biography\", \"Biography\"], + settings.biography, + \"textarea\", + ], + [ + [\"private_biography\", \"Private biography\"], + settings.private_biography, + \"textarea\", + { + embed_html: + 'This biography is only shown to users you are not following while your account is private.', + }, + ], + [[\"status\", \"Status\"], settings.status, \"textarea\"], + [ + [\"warning\", \"Profile warning\"], + settings.warning, + \"textarea\", + ], + [[\"muted\", \"Muted phrases\"], settings.muted.join(\"\\n\"), \"textarea\", { + embed_html: + 'Muted phrases should all be on new lines.', + }], + [[], \"Accessibility\", \"title\"], + [ + [\"large_text\", \"Increase UI text size\"], + \"{{ profile.settings.large_text }}\", + \"checkbox\", + ], + [ + [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"], + \"{{ profile.settings.paged_timelines }}\", + \"checkbox\", + ], + [ + [\"auto_clear_notifs\", \"Automatically clear all notifications when you open the notifications page\"], + \"{{ profile.settings.auto_clear_notifs }}\", + \"checkbox\", + ], + ], + settings, + { + muted: (new_muted) => { + settings.muted = new_muted + .split(\"\\n\") + .map((t) => t.trim()); + }, + }, + ); + + ui.generate_settings_ui( + profile_settings, + [ + [[], \"Privacy\", \"title\"], + [ + [ + \"require_account\", + \"Require an account to view my profile\", + ], + \"{{ profile.settings.require_account }}\", + \"checkbox\", + ], + [ + [ + \"private_profile\", + \"Only allow users I'm following to view my profile\", + ], + \"{{ profile.settings.private_profile }}\", + \"checkbox\", + ], + [ + [ + \"private_chats\", + \"Only allow users I'm following to add me to chats\", + ], + \"{{ profile.settings.private_chats }}\", + \"checkbox\", + ], + [ + [ + \"private_mails\", + \"Only allow users I'm following to send me mail\", + ], + \"{{ profile.settings.private_mails }}\", + \"checkbox\", + ], + [ + [ + \"private_communities\", + \"Keep my joined communities private\", + ], + \"{{ profile.settings.private_communities }}\", + \"checkbox\", + ], + [ + [\"private_last_seen\", \"Keep my last seen time private\"], + \"{{ profile.settings.private_last_seen }}\", + \"checkbox\", + ], + [ + [\"hide_extra_post_tabs\", \"Hide extra post tabs (replies, media)\"], + \"{{ profile.settings.hide_extra_post_tabs }}\", + \"checkbox\", + ], + [ + [\"show_nsfw\", \"Show NSFW posts\"], + \"{{ profile.settings.show_nsfw }}\", + \"checkbox\", + ], + [ + [\"auto_unlist\", \"Automatically mark my posts as NSFW\"], + \"{{ profile.settings.auto_unlist }}\", + \"checkbox\", + ], + [ + [\"auto_full_unlist\", \"Only publish my posts to my profile\"], + \"{{ profile.settings.auto_full_unlist }}\", + \"checkbox\", + ], + [ + [], + \"Your posts will still be visible in the \\\"Following\\\" timeline for users following you.\", + \"text\", + ], + [ + [\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'], + \"{{ profile.settings.all_timeline_hide_answers }}\", + \"checkbox\", + ], + [ + [ + \"hide_associated_blocked_users\", + \"Hide users that you've blocked on your other accounts from timelines\", + ], + \"{{ profile.settings.hide_associated_blocked_users }}\", + \"checkbox\", + ], + [ + [ + \"hide_from_social_lists\", + \"Hide my profile from social lists (followers/following)\", + ], + \"{{ profile.settings.hide_from_social_lists }}\", + \"checkbox\", + ], + [ + [ + \"hide_social_follows\", + \"Hide followers/following links on my profile\", + ], + \"{{ profile.settings.hide_social_follows }}\", + \"checkbox\", + ], + [ + [ + \"hide_username_badges\", + \"Hide badges from your username (outside of your profile)\", + ], + \"{{ profile.settings.hide_username_badges }}\", + \"checkbox\", + ], + [[], \"Questions\", \"title\"], + [ + [ + \"enable_questions\", + \"Allow users to ask you questions\", + ], + \"{{ profile.settings.enable_questions }}\", + \"checkbox\", + ], + [ + [ + \"allow_anonymous_questions\", + \"Allow anonymous questions\", + ], + \"{{ profile.settings.allow_anonymous_questions }}\", + \"checkbox\", + ], + [ + [ + \"enable_drawings\", + \"Allow users to create drawings and submit them with questions\", + ], + \"{{ profile.settings.enable_drawings }}\", + \"checkbox\", + ], + [ + [\"motivational_header\", \"Motivational header\"], + settings.motivational_header, + \"input\", + ], + [[], \"Anonymous\", \"title\"], + [ + [\"anonymous_username\", \"Anonymous username\"], + settings.anonymous_username, + \"input\", + ], + [ + [\"anonymous_avatar_url\", \"Anonymous avatar URL\"], + settings.anonymous_avatar_url, + \"input\", + ], + [[], \"Signatures\", \"title\"], + [ + [\"mail_signature\", \"Mail signature\"], + settings.mail_signature, + \"textarea\", + ], + [ + [\"forum_signature\", \"Forum signature\"], + settings.forum_signature, + \"textarea\", + ], + [[], \"Economy\", \"title\"], + [ + [ + \"enable_shop\", + \"Show shop tab on my profile\", + ], + \"{{ profile.settings.enable_shop }}\", + \"checkbox\", + ], + [ + [ + \"no_transfers\", + \"Disable transfer requests\", + ], + \"{{ profile.settings.no_transfers }}\", + \"checkbox\", + ], + [[], \"Misc\", \"title\"], + [ + [\"hide_dislikes\", \"Hide post dislikes\"], + \"{{ profile.settings.hide_dislikes }}\", + \"checkbox\", + ], + [ + [], + \"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\", + \"text\", + ], + [[], \"Fun\", \"title\"], + [ + [\"disable_achievements\", \"Disable achievements\"], + \"{{ profile.settings.disable_achievements }}\", + \"checkbox\", + ], + ], + settings, + ); + + const can_use_custom_css = + \"{{ user.permissions|has_supporter }}\" === \"true\"; + + const theme_settings_ui_json = [ + [ + [ + \"disable_other_themes\", + \"Disable the profile theme of other users\", + ], + \"{{ profile.settings.disable_other_themes }}\", + \"checkbox\", + ], + [[], \"Theme builder\", \"title\"], + [ + [], + \"Allow the site to build the theme for you given a base hue, saturation, and lightness. Scroll down to the next section to manually build the theme.\", + \"text\", + ], + [ + [\"theme_hue\", \"Theme hue (integer 0-255)\"], + \"{{ profile.settings.theme_hue }}\", \"input\", ], [ - [\"biography\", \"Biography\"], - settings.biography, - \"textarea\", + [\"theme_sat\", \"Theme sat (percentage 0%-100%)\"], + \"{{ profile.settings.theme_sat }}\", + \"input\", ], [ - [\"private_biography\", \"Private biography\"], - settings.private_biography, + [\"theme_lit\", \"Theme lit (percentage 0%-100%)\"], + \"{{ profile.settings.theme_lit }}\", + \"input\", + ], + [[], \"Manual theme builder\", \"title\"], + [[], \"Override individual colors.\", \"text\"], + // surface + [ + [\"theme_color_surface\", \"Surface\"], + \"{{ profile.settings.theme_color_surface }}\", + \"color\", + { + description: \"Page background.\", + }, + ], + [ + [\"theme_color_text\", \"Text\"], + \"{{ profile.settings.theme_color_text }}\", + \"color\", + { + description: + \"Text on elements with the surface background.\", + }, + ], + [ + [\"theme_color_text_link\", \"Links\"], + \"{{ profile.settings.theme_color_text_link }}\", + \"color\", + { + description: \"Links on all elements.\", + }, + ], + // lowered + [[], \"\", \"divider\"], + [ + [\"theme_color_lowered\", \"Lowered\"], + \"{{ profile.settings.theme_color_lowered }}\", + \"color\", + { + description: + \"Some cards, buttons, or anything else with a darker background color than the surface.\", + }, + ], + [ + [\"theme_color_text_lowered\", \"Text\"], + \"{{ profile.settings.theme_color_text_lowered }}\", + \"color\", + { + description: + \"Text on elements with the lowered backgrounds.\", + }, + ], + [ + [\"theme_color_super_lowered\", \"Super lowered\"], + \"{{ profile.settings.theme_color_super_lowered }}\", + \"color\", + { + description: \"Borders.\", + }, + ], + // raised + [[], \"\", \"divider\"], + [ + [\"theme_color_raised\", \"Raised\"], + \"{{ profile.settings.theme_color_raised }}\", + \"color\", + { + description: + \"Some cards, buttons, or anything else with a lighter background color than the surface.\", + }, + ], + [ + [\"theme_color_text_raised\", \"Text\"], + \"{{ profile.settings.theme_color_text_raised }}\", + \"color\", + { + description: + \"Text on elements with the raised backgrounds.\", + }, + ], + [ + [\"theme_color_super_raised\", \"Super raised\"], + \"{{ profile.settings.theme_color_super_raised }}\", + \"color\", + { + description: \"Some borders.\", + }, + ], + // primary + [[], \"\", \"divider\"], + [ + [\"theme_color_primary\", \"Primary\"], + \"{{ profile.settings.theme_color_primary }}\", + \"color\", + { + description: + \"Primary color; navigation bar, some buttons, etc.\", + }, + ], + [ + [\"theme_color_text_primary\", \"Text\"], + \"{{ profile.settings.theme_color_text_primary }}\", + \"color\", + { + description: + \"Text on elements with the primary backgrounds.\", + }, + ], + [ + [\"theme_color_primary_lowered\", \"Lowered\"], + \"{{ profile.settings.theme_color_primary_lowered }}\", + \"color\", + { + description: \"Hover state for primary buttons.\", + }, + ], + // secondary + [[], \"\", \"divider\"], + [ + [\"theme_color_secondary\", \"Secondary\"], + \"{{ profile.settings.theme_color_secondary }}\", + \"color\", + { + description: \"Secondary color.\", + }, + ], + [ + [\"theme_color_text_secondary\", \"Text\"], + \"{{ profile.settings.theme_color_text_secondary }}\", + \"color\", + { + description: + \"Text on elements with the secondary backgrounds.\", + }, + ], + [ + [\"theme_color_secondary_lowered\", \"Lowered\"], + \"{{ profile.settings.theme_color_secondary_lowered }}\", + \"color\", + { + description: \"Hover state for secondary buttons.\", + }, + ], + // online indicator + [[], \"\", \"divider\"], + [ + [\"theme_color_online\", \"Online indicator (online)\"], + \"{{ profile.settings.theme_color_online }}\", + \"color\", + { + description: + \"The green dot next to the name of online users.\", + }, + ], + [ + [\"theme_color_idle\", \"Online indicator (idle)\"], + \"{{ profile.settings.theme_color_idle }}\", + \"color\", + { + description: + \"The yellow dot next to the name of online users.\", + }, + ], + [ + [\"theme_color_offline\", \"Online indicator (offline)\"], + \"{{ profile.settings.theme_color_offline }}\", + \"color\", + { + description: + \"The grey next to the name of online users.\", + }, + ], + ]; + + if (can_use_custom_css) { + theme_settings_ui_json.push([[], \"Advanced\", \"title\"]); + theme_settings_ui_json.push([ + [\"theme_custom_css\", \"Custom CSS\"], + settings.theme_custom_css, \"textarea\", { embed_html: - 'This biography is only shown to users you are not following while your account is private.', + 'Custom CSS input embedded into your theme.', }, - ], - [[\"status\", \"Status\"], settings.status, \"textarea\"], - [ - [\"warning\", \"Profile warning\"], - settings.warning, - \"textarea\", - ], - [[\"muted\", \"Muted phrases\"], settings.muted.join(\"\\n\"), \"textarea\", { - embed_html: - 'Muted phrases should all be on new lines.', - }], - [[], \"Accessibility\", \"title\"], - [ - [\"large_text\", \"Increase UI text size\"], - \"{{ profile.settings.large_text }}\", - \"checkbox\", - ], - [ - [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"], - \"{{ profile.settings.paged_timelines }}\", - \"checkbox\", - ], - [ - [\"auto_clear_notifs\", \"Automatically clear all notifications when you open the notifications page\"], - \"{{ profile.settings.auto_clear_notifs }}\", - \"checkbox\", - ], - ], - settings, - { - muted: (new_muted) => { - settings.muted = new_muted - .split(\"\\n\") - .map((t) => t.trim()); - }, - }, - ); - - ui.generate_settings_ui( - profile_settings, - [ - [[], \"Privacy\", \"title\"], - [ - [ - \"require_account\", - \"Require an account to view my profile\", - ], - \"{{ profile.settings.require_account }}\", - \"checkbox\", - ], - [ - [ - \"private_profile\", - \"Only allow users I'm following to view my profile\", - ], - \"{{ profile.settings.private_profile }}\", - \"checkbox\", - ], - [ - [ - \"private_chats\", - \"Only allow users I'm following to add me to chats\", - ], - \"{{ profile.settings.private_chats }}\", - \"checkbox\", - ], - [ - [ - \"private_mails\", - \"Only allow users I'm following to send me mail\", - ], - \"{{ profile.settings.private_mails }}\", - \"checkbox\", - ], - [ - [ - \"private_communities\", - \"Keep my joined communities private\", - ], - \"{{ profile.settings.private_communities }}\", - \"checkbox\", - ], - [ - [\"private_last_seen\", \"Keep my last seen time private\"], - \"{{ profile.settings.private_last_seen }}\", - \"checkbox\", - ], - [ - [\"hide_extra_post_tabs\", \"Hide extra post tabs (replies, media)\"], - \"{{ profile.settings.hide_extra_post_tabs }}\", - \"checkbox\", - ], - [ - [\"show_nsfw\", \"Show NSFW posts\"], - \"{{ profile.settings.show_nsfw }}\", - \"checkbox\", - ], - [ - [\"auto_unlist\", \"Automatically mark my posts as NSFW\"], - \"{{ profile.settings.auto_unlist }}\", - \"checkbox\", - ], - [ - [\"auto_full_unlist\", \"Only publish my posts to my profile\"], - \"{{ profile.settings.auto_full_unlist }}\", - \"checkbox\", - ], - [ - [], - \"Your posts will still be visible in the \\\"Following\\\" timeline for users following you.\", - \"text\", - ], - [ - [\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'], - \"{{ profile.settings.all_timeline_hide_answers }}\", - \"checkbox\", - ], - [ - [ - \"hide_associated_blocked_users\", - \"Hide users that you've blocked on your other accounts from timelines\", - ], - \"{{ profile.settings.hide_associated_blocked_users }}\", - \"checkbox\", - ], - [ - [ - \"hide_from_social_lists\", - \"Hide my profile from social lists (followers/following)\", - ], - \"{{ profile.settings.hide_from_social_lists }}\", - \"checkbox\", - ], - [ - [ - \"hide_social_follows\", - \"Hide followers/following links on my profile\", - ], - \"{{ profile.settings.hide_social_follows }}\", - \"checkbox\", - ], - [ - [ - \"hide_username_badges\", - \"Hide badges from your username (outside of your profile)\", - ], - \"{{ profile.settings.hide_username_badges }}\", - \"checkbox\", - ], - [[], \"Questions\", \"title\"], - [ - [ - \"enable_questions\", - \"Allow users to ask you questions\", - ], - \"{{ profile.settings.enable_questions }}\", - \"checkbox\", - ], - [ - [ - \"allow_anonymous_questions\", - \"Allow anonymous questions\", - ], - \"{{ profile.settings.allow_anonymous_questions }}\", - \"checkbox\", - ], - [ - [ - \"enable_drawings\", - \"Allow users to create drawings and submit them with questions\", - ], - \"{{ profile.settings.enable_drawings }}\", - \"checkbox\", - ], - [ - [\"motivational_header\", \"Motivational header\"], - settings.motivational_header, - \"input\", - ], - [[], \"Anonymous\", \"title\"], - [ - [\"anonymous_username\", \"Anonymous username\"], - settings.anonymous_username, - \"input\", - ], - [ - [\"anonymous_avatar_url\", \"Anonymous avatar URL\"], - settings.anonymous_avatar_url, - \"input\", - ], - [[], \"Signatures\", \"title\"], - [ - [\"mail_signature\", \"Mail signature\"], - settings.mail_signature, - \"textarea\", - ], - [ - [\"forum_signature\", \"Forum signature\"], - settings.forum_signature, - \"textarea\", - ], - [[], \"Economy\", \"title\"], - [ - [ - \"enable_shop\", - \"Show shop tab on my profile\", - ], - \"{{ profile.settings.enable_shop }}\", - \"checkbox\", - ], - [ - [ - \"no_transfers\", - \"Disable transfer requests\", - ], - \"{{ profile.settings.no_transfers }}\", - \"checkbox\", - ], - [[], \"Misc\", \"title\"], - [ - [\"hide_dislikes\", \"Hide post dislikes\"], - \"{{ profile.settings.hide_dislikes }}\", - \"checkbox\", - ], - [ - [], - \"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\", - \"text\", - ], - [[], \"Fun\", \"title\"], - [ - [\"disable_achievements\", \"Disable achievements\"], - \"{{ profile.settings.disable_achievements }}\", - \"checkbox\", - ], - ], - settings, - ); - - const can_use_custom_css = - \"{{ user.permissions|has_supporter }}\" === \"true\"; - - const theme_settings_ui_json = [ - [ - [ - \"disable_other_themes\", - \"Disable the profile theme of other users\", - ], - \"{{ profile.settings.disable_other_themes }}\", - \"checkbox\", - ], - [[], \"Theme builder\", \"title\"], - [ - [], - \"Allow the site to build the theme for you given a base hue, saturation, and lightness. Scroll down to the next section to manually build the theme.\", - \"text\", - ], - [ - [\"theme_hue\", \"Theme hue (integer 0-255)\"], - \"{{ profile.settings.theme_hue }}\", - \"input\", - ], - [ - [\"theme_sat\", \"Theme sat (percentage 0%-100%)\"], - \"{{ profile.settings.theme_sat }}\", - \"input\", - ], - [ - [\"theme_lit\", \"Theme lit (percentage 0%-100%)\"], - \"{{ profile.settings.theme_lit }}\", - \"input\", - ], - [[], \"Manual theme builder\", \"title\"], - [[], \"Override individual colors.\", \"text\"], - // surface - [ - [\"theme_color_surface\", \"Surface\"], - \"{{ profile.settings.theme_color_surface }}\", - \"color\", - { - description: \"Page background.\", - }, - ], - [ - [\"theme_color_text\", \"Text\"], - \"{{ profile.settings.theme_color_text }}\", - \"color\", - { - description: - \"Text on elements with the surface background.\", - }, - ], - [ - [\"theme_color_text_link\", \"Links\"], - \"{{ profile.settings.theme_color_text_link }}\", - \"color\", - { - description: \"Links on all elements.\", - }, - ], - // lowered - [[], \"\", \"divider\"], - [ - [\"theme_color_lowered\", \"Lowered\"], - \"{{ profile.settings.theme_color_lowered }}\", - \"color\", - { - description: - \"Some cards, buttons, or anything else with a darker background color than the surface.\", - }, - ], - [ - [\"theme_color_text_lowered\", \"Text\"], - \"{{ profile.settings.theme_color_text_lowered }}\", - \"color\", - { - description: - \"Text on elements with the lowered backgrounds.\", - }, - ], - [ - [\"theme_color_super_lowered\", \"Super lowered\"], - \"{{ profile.settings.theme_color_super_lowered }}\", - \"color\", - { - description: \"Borders.\", - }, - ], - // raised - [[], \"\", \"divider\"], - [ - [\"theme_color_raised\", \"Raised\"], - \"{{ profile.settings.theme_color_raised }}\", - \"color\", - { - description: - \"Some cards, buttons, or anything else with a lighter background color than the surface.\", - }, - ], - [ - [\"theme_color_text_raised\", \"Text\"], - \"{{ profile.settings.theme_color_text_raised }}\", - \"color\", - { - description: - \"Text on elements with the raised backgrounds.\", - }, - ], - [ - [\"theme_color_super_raised\", \"Super raised\"], - \"{{ profile.settings.theme_color_super_raised }}\", - \"color\", - { - description: \"Some borders.\", - }, - ], - // primary - [[], \"\", \"divider\"], - [ - [\"theme_color_primary\", \"Primary\"], - \"{{ profile.settings.theme_color_primary }}\", - \"color\", - { - description: - \"Primary color; navigation bar, some buttons, etc.\", - }, - ], - [ - [\"theme_color_text_primary\", \"Text\"], - \"{{ profile.settings.theme_color_text_primary }}\", - \"color\", - { - description: - \"Text on elements with the primary backgrounds.\", - }, - ], - [ - [\"theme_color_primary_lowered\", \"Lowered\"], - \"{{ profile.settings.theme_color_primary_lowered }}\", - \"color\", - { - description: \"Hover state for primary buttons.\", - }, - ], - // secondary - [[], \"\", \"divider\"], - [ - [\"theme_color_secondary\", \"Secondary\"], - \"{{ profile.settings.theme_color_secondary }}\", - \"color\", - { - description: \"Secondary color.\", - }, - ], - [ - [\"theme_color_text_secondary\", \"Text\"], - \"{{ profile.settings.theme_color_text_secondary }}\", - \"color\", - { - description: - \"Text on elements with the secondary backgrounds.\", - }, - ], - [ - [\"theme_color_secondary_lowered\", \"Lowered\"], - \"{{ profile.settings.theme_color_secondary_lowered }}\", - \"color\", - { - description: \"Hover state for secondary buttons.\", - }, - ], - // online indicator - [[], \"\", \"divider\"], - [ - [\"theme_color_online\", \"Online indicator (online)\"], - \"{{ profile.settings.theme_color_online }}\", - \"color\", - { - description: - \"The green dot next to the name of online users.\", - }, - ], - [ - [\"theme_color_idle\", \"Online indicator (idle)\"], - \"{{ profile.settings.theme_color_idle }}\", - \"color\", - { - description: - \"The yellow dot next to the name of online users.\", - }, - ], - [ - [\"theme_color_offline\", \"Online indicator (offline)\"], - \"{{ profile.settings.theme_color_offline }}\", - \"color\", - { - description: - \"The grey next to the name of online users.\", - }, - ], - ]; - - if (can_use_custom_css) { - theme_settings_ui_json.push([[], \"Advanced\", \"title\"]); - theme_settings_ui_json.push([ - [\"theme_custom_css\", \"Custom CSS\"], - settings.theme_custom_css, - \"textarea\", - { - embed_html: - 'Custom CSS input embedded into your theme.', - }, - ]); - } - - ui.generate_settings_ui( - theme_settings, - theme_settings_ui_json, - settings, - ); - - globalThis.import_theme_settings = () => { - const input = document.createElement(\"input\"); - input.type = \"file\"; - input.accept = \"application/json\"; - document.body.appendChild(input); - - input.addEventListener(\"change\", async (e) => { - const json = JSON.parse(await e.target.files[0].text()); - - for (const setting of Object.entries(json)) { - settings[setting[0]] = setting[1]; - } - - input.remove(); - save_settings(); - - setTimeout(() => { - window.location.reload(); - }, 150); - }); - - input.click(); - }; - - globalThis.export_theme_settings = () => { - const theme_settings = { - profile_theme: settings.profile_theme, - }; - - for (const setting of Object.entries(settings)) { - if (setting[0].startsWith(\"theme_\")) { - theme_settings[setting[0]] = setting[1]; - } + ]); } - const blob = new Blob( - [JSON.stringify(theme_settings, null, 4)], - { - type: \"appliction/json\", - }, + ui.generate_settings_ui( + theme_settings, + theme_settings_ui_json, + settings, ); - const url = URL.createObjectURL(blob); - const anchor = document.createElement(\"a\"); - anchor.href = url; - anchor.setAttribute(\"download\", \"theme.json\"); + globalThis.import_theme_settings = () => { + const input = document.createElement(\"input\"); + input.type = \"file\"; + input.accept = \"application/json\"; + document.body.appendChild(input); - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - }; - });"))) + input.addEventListener(\"change\", async (e) => { + const json = JSON.parse(await e.target.files[0].text()); + for (const setting of Object.entries(json)) { + settings[setting[0]] = setting[1]; + } + + input.remove(); + save_settings(); + + setTimeout(() => { + window.location.reload(); + }, 150); + }); + + input.click(); + }; + + globalThis.export_theme_settings = () => { + const theme_settings = { + profile_theme: settings.profile_theme, + }; + + for (const setting of Object.entries(settings)) { + if (setting[0].startsWith(\"theme_\")) { + theme_settings[setting[0]] = setting[1]; + } + } + + const blob = new Blob( + [JSON.stringify(theme_settings, null, 4)], + { + type: \"appliction/json\", + }, + ); + const url = URL.createObjectURL(blob); + + const anchor = document.createElement(\"a\"); + anchor.href = url; + anchor.setAttribute(\"download\", \"theme.json\"); + + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + }; + });")))) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/search.lisp b/crates/app/src/public/html/timelines/search.lisp index f25fbe1..4adcd61 100644 --- a/crates/app/src/public/html/timelines/search.lisp +++ b/crates/app/src/public/html/timelines/search.lisp @@ -23,7 +23,7 @@ ("class" "card w_full flex flex_col gap_2") (text "{% if not profile and not user.permissions|has_supporter -%} {{ components::supporter_ad(body=\"Become a supporter for full-site search!\") }} {% else %}") (form - ("class" "flex flex_col gap_2") + ("class" "flex flex_col gap_2 round_form") (div ("class" "flex flex_row gap_2") (input @@ -57,5 +57,4 @@ (text "{%- endif %}")))) (text "{%- endif %}") (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {% if profile -%} {{ components::pagination(page=page, items=list|length, key=\"&profile=\" ~ profile.id, value=\"&query=\" ~ query) }} {% else %} {{ components::pagination(page=page, items=list|length, key=\"&query=\" ~ query) }} {%- endif %}")))) - (text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index 2e777b1..98b8190 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -217,21 +217,24 @@ pub async fn add_user_request( Err(e) => return Json(Error::MiscError(e.to_string()).into()), }; - // check block status - if data - .get_userblock_by_initiator_receiver(other_user.id, user.id) - .await - .is_ok() - { - return Json(Error::NotAllowed.into()); - } - - // add user + // get stack let mut stack = match data.get_stack_by_id(id).await { Ok(s) => s, Err(e) => return Json(e.into()), }; + // check block status + if stack.mode != StackMode::BlockList { + if data + .get_userblock_by_initiator_receiver(other_user.id, user.id) + .await + .is_ok() + { + return Json(Error::NotAllowed.into()); + } + } + + // add user if stack.users.contains(&other_user.id) { return Json(Error::MiscError("This user is already in this stack".to_string()).into()); } diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 92aed8d..d888dfe 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -9,7 +9,7 @@ use axum::{ Extension, }; use crate::cookie::CookieJar; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tetratto_core::{ database::FullPost, model::{ @@ -670,6 +670,20 @@ pub async fn search_request( )) } +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +pub enum TimelineOrderMode { + /// Ordered by creation date. + Recent, + /// Ordered by likes - dislikes. + Popular, +} + +impl Default for TimelineOrderMode { + fn default() -> Self { + Self::Recent + } +} + #[derive(Deserialize)] pub struct TimelineQuery { #[serde(default)] @@ -688,6 +702,8 @@ pub struct TimelineQuery { pub before: usize, #[serde(default)] pub responses_only: bool, + #[serde(default)] + pub order: TimelineOrderMode, } async fn swiss_army_timeline( @@ -737,7 +753,13 @@ async fn swiss_army_timeline( .get_responses_by_user(req.user_id, 12, req.page) .await } else { - data.0.get_posts_by_user(req.user_id, 12, req.page).await + if req.order == TimelineOrderMode::Recent { + data.0.get_posts_by_user(req.user_id, 12, req.page).await + } else { + data.0 + .get_popular_posts_by_user(req.user_id, 12, req.page) + .await + } } } else { if req.responses_only { diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index e147c1b..8ab6da5 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -16,7 +16,7 @@ use axum::{ routing::{get, post}, Router, }; -use crate::cookie::CookieJar; +use crate::{cookie::CookieJar, routes::pages::misc::TimelineOrderMode}; use serde::Deserialize; use tetratto_core::model::{Error, auth::User}; use crate::{assets::initial_context, get_lang, InnerState}; @@ -222,6 +222,8 @@ pub struct ProfileQuery { pub responses_only: bool, #[serde(default, alias = "f")] pub force: bool, + #[serde(default, alias = "o")] + pub order: TimelineOrderMode, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 3b1df41..b2de30b 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -380,6 +380,7 @@ pub async fn posts_request( context.insert("pinned", &pinned); context.insert("page", &props.page); context.insert("tag", &props.tag); + context.insert("order", &props.order); profile_context( &mut context, &user, diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 0bda649..288ac13 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -783,6 +783,37 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts from the given user (sorted by likes - dislikes). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_popular_posts_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM posts WHERE owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 0 ORDER BY (likes - dislikes) DESC, created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get all posts (that are answering a question) from the given user (from most recent). /// /// # Arguments