diff --git a/app/javascript/src/javascripts/tabs.js b/app/javascript/src/javascripts/tabs.js new file mode 100644 index 000000000..ef0f4e26e --- /dev/null +++ b/app/javascript/src/javascripts/tabs.js @@ -0,0 +1,170 @@ +class Tabs { + + constructor ($element) { + this.$menu = $element; + + const id = this.$menu.attr("id"); + const pagesWrap = $(`tabs-content[for="${id}"]`); + if (!pagesWrap) { + console.error("E6.Tabs", "No content"); + return; + } + + // Create page and search indices + this.index = new TabIndex(pagesWrap, this.$menu); + + // Bootstrap search + const input = this.$menu.find("input[name='search']").on("input", (event, crabs) => { + const val = (input.val() + "").trim(); + this.index.find(val); + + // Set the query param + if (crabs) return; + + const url = new URL(window.location); + if (!val) url.searchParams.delete("find"); + else url.searchParams.set("find", input.val() + ""); + url.searchParams.delete("tab"); + + window.history.pushState({}, "", url); + }); + + // Bootstrap tab buttons + const firstButtonName = this.$menu.find("button").first().attr("name"); + this.$menu.on("click", "button", (event, crabs) => { + const button = $(event.currentTarget); + const name = button.attr("name"); + if (!name) return; + + // Toggle the page + this.index.openPage(name); + this.$menu.find("button.active").removeClass("active"); + button.addClass("active"); + input.val(""); + + // Set the query param + if (crabs) return; + + const url = new URL(window.location); + if (name == firstButtonName) url.searchParams.delete("tab"); + else url.searchParams.set("tab", name); + url.searchParams.delete("find"); + + window.history.pushState({}, "", url); + }); + + // Attempt to restore previous state + const queryParams = new URLSearchParams(window.location.search); + if (queryParams.get("tab")) { + // No error handling: if the button does not exist nothing loads + this.$menu.find(`button[name="${queryParams.get("tab")}"]`).trigger("click", [ true ]); + input.val(""); + } else if (queryParams.get("find")) { + input.val(queryParams.get("find"), [ true ]); + input.trigger("input"); + } else { + // Just open the first tab + this.$menu.find("button").first().click(); + input.val(""); + } + } +} + +class TabIndex { + + constructor (wrapper, $menu) { + + this.$menu = $menu; + + this.pages = {}; + this.search = {}; + this._allEntries = wrapper.children("tab-entry"); + for (const one of this._allEntries) { + const $one = $(one); + + const tab = $one.attr("tab"); + if (tab) { + if (!this.pages[tab]) this.pages[tab] = []; + this.pages[tab].push($one); + } + + // TODO Not a great way of doing this. + // Entries with the same search string will get overwriten. + const search = $one.attr("search"); + if (search) this.search[search] = $one; + } + + this.groups = {}; + this._allGroups = wrapper.children("tab-group"); + for (const one of this._allGroups) { + const $one = $(one); + const name = $one.attr("name"); + if (!name) return; + this.groups[name] = $one; + } + } + + + /** + * Show all entries on a specific tab + * @param {string} name Tab name + */ + openPage (name) { + + if (!name || !this.pages[name]) { + console.error("E6.Tabs", name, "does not exist"); + return; + } + + // Activate tab entries + const groups = new Set(); + this._allEntries.removeClass("active"); + for (const entry of this.pages[name]) { + entry.addClass("active"); + if (entry.attr("group")) + groups.add(entry.attr("group")); + } + + // Activate group headers + this._allGroups.removeClass("active"); + for (const group of groups) + this.groups[group].addClass("active"); + } + + + /** + * Find settings inputs based on keywords + * @param {string} query Search query + */ + find (query) { + this._allEntries.removeClass("active"); + this._allGroups.removeClass("active"); + + // Restore the previous session + if (query.length == 0) { + this.$menu.find("button").first().trigger("click", [ false ]); + return; + } + + const terms = query.split(" "); + const groups = new Set(); + for (const [tags, $element] of Object.entries(this.search)) { + for (const term of terms) { + if (!tags.includes(term)) continue; + $element.addClass("active"); + if ($element.attr("group")) + groups.add($element.attr("group")); + } + } + + // Activate group headers + this._allGroups.removeClass("active"); + for (const group of groups) + this.groups[group].addClass("active"); + } +} + +$(() => { + for (const one of $("tabs-menu")) + new Tabs($(one)); +}); diff --git a/app/javascript/src/styles/base.scss b/app/javascript/src/styles/base.scss index 2cb54aaad..80a7cebe8 100644 --- a/app/javascript/src/styles/base.scss +++ b/app/javascript/src/styles/base.scss @@ -44,6 +44,7 @@ @import "common/voting.scss"; @import "views/posts/posts"; +@import "views/users/users"; @import "specific/admin_dashboards.scss"; @import "specific/api_keys.scss"; diff --git a/app/javascript/src/styles/common/_standard_elements.scss b/app/javascript/src/styles/common/_standard_elements.scss index 7de3c1be4..686aa32f4 100644 --- a/app/javascript/src/styles/common/_standard_elements.scss +++ b/app/javascript/src/styles/common/_standard_elements.scss @@ -21,11 +21,12 @@ line-height: st-value(100); padding: st-value(100) / 2; height: st-value(100) * 2; + box-sizing: border-box; // TODO What if button is on a light background background: $button-background; color: $button-text-color; - &:hover { background: $button-background-hover; } + &:hover, &:active { background: $button-background-hover; } & > svg { // Icon should be slightly larger than text, @@ -64,3 +65,53 @@ padding: (st-value(100) / 2) 0; } } + + +// Colored buttons +.st-button.danger { + background: palette("background-red"); + &:hover, &:active { + background: palette("background-red-d5") + } +} + + + +// Toggle switch +label.st-toggle { + display: flex; + width: 2.5rem; + height: 1.5rem; + box-sizing: border-box; + position: relative; + + background-color: themed("color-foreground"); + transition: background-color 500ms; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0; + + box-shadow: inset 0 0 0.25rem themed("color-background"); + + &::after { + content: ""; + width: 1rem; + height: 1rem; + position: absolute; + top: 0.25rem; + left: 0.25rem; + + background: themed("color-text-muted"); + border-radius: 0.25rem; + transition: left 100ms; + } +} +input[type="checkbox"][disabled].st-toggle + label.st-toggle { + background: palette("background-grey"); + cursor: not-allowed; +} +input[type="checkbox"].st-toggle { display: none; } +input[type="checkbox"].st-toggle:checked + label.st-toggle { + background-color: palette("background-green-d5"); + &::after { left: 1.25rem; } +} diff --git a/app/javascript/src/styles/specific/users.scss b/app/javascript/src/styles/specific/users.scss index 0108e4c8c..c84173453 100644 --- a/app/javascript/src/styles/specific/users.scss +++ b/app/javascript/src/styles/specific/users.scss @@ -200,29 +200,6 @@ div#c-users { } } } - - div#a-edit { - h1 { - margin: 0.5em 0; - } - - h2 { - margin: 0.5em 0; - } - - div.input { - margin-bottom: 2em; - } - - div.input span.hint { - display: block; - max-width: 70%; - } - - .active { - color: themed("color-link-active"); - } - } } // User signup and login diff --git a/app/javascript/src/styles/views/users/_users.scss b/app/javascript/src/styles/views/users/_users.scss new file mode 100644 index 000000000..1fe06b79f --- /dev/null +++ b/app/javascript/src/styles/views/users/_users.scss @@ -0,0 +1 @@ +@import "edit/edit"; diff --git a/app/javascript/src/styles/views/users/edit/_edit.scss b/app/javascript/src/styles/views/users/edit/_edit.scss new file mode 100644 index 000000000..128c1d984 --- /dev/null +++ b/app/javascript/src/styles/views/users/edit/_edit.scss @@ -0,0 +1,199 @@ +body.c-users.a-edit { + + form.simple_form { + background: unset; + padding: unset; + margin: unset; + + input[type="text"], input[type="number"], textarea, select { + width: 100%; + max-width: unset; + box-sizing: border-box; + border-radius: 0.25rem; + font-size: 1rem; + padding: 0.25rem; + } + } + + #settings-account-buttons tab-body { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + + .st-button { + white-space: nowrap; + justify-content: center; + } + } + + tab-submit { + input[type="submit"] { + background: themed("color-tag-artist"); + font-size: 1.25rem; + padding: 0.25rem 1rem; + margin: 0.5rem; + border-radius: 0.25rem; + + &:hover, &:active { background: themed("color-tag-artist-alt"); } + } + } +} + + + + +// Tabs section +tabs-menu { + margin-top: 0.5rem; + display: flex; + width: 100%; + max-width: 50rem; + flex-wrap: wrap; + + // TODO Smooth drag when overflowing + overflow: auto; + + & > button { + background: unset; + color: themed("color-link"); + padding: 0.5rem 1rem; + border-bottom: 2px solid themed("color-section"); + font-size: 1rem; + line-height: 1rem; + + &.active { + background: themed("color-section"); + border-radius: 0.25rem 0.25rem 0 0; + &:first-child { + border-bottom-left-radius: 0.25rem; + } + } + } + + span.spacer { + flex: 1; + border-bottom: 2px solid themed("color-section"); + } + + input[name="search"] { + border-radius: 0 0 0.25rem 0.25rem; + font-size: 1rem; + line-height: 1rem; + padding: 0.25rem; + width: 100%; + box-sizing: border-box; + } +} + + +// Tabs structure +tabs-content { + display: flex; + flex-flow: column; + max-width: 50rem; + + tab-group { + display: none; + &.active { display: flex; } + + font-size: 1rem; + line-height: 1rem; + padding: 0.5rem 0.75rem 0.5rem; + margin: 0.5rem 0 0; + + background: themed("color-section-lighten-5"); + border-radius: 0.25rem 0.25rem 0 0; + } + + tab-entry { + display: none; + &.active { display: grid; } + grid-template-areas: "head" "body" "hint"; + grid-template-columns: 1fr; + + align-items: center; + gap: 0.25rem; + + background: themed("color-section"); + padding: 0.5rem; + + tab-head { + grid-area: head; + margin-left: 0.25em; + font-size: 0.85rem; + + label { font-weight: normal; } + } + + tab-body { + grid-area: body; + } + + tab-hint { + grid-area: hint; + + font-size: 90%; + color: themed("color-text-muted"); + } + + &.inline { + grid-template-areas: "head body" "hint hint"; + grid-template-columns: 2fr 1fr; + + tab-body { justify-self: end; } + } + + &.blocky { + grid-template-areas: "body"; + grid-template-columns: 1fr; + } + + &.bigtext { + grid-template-columns: 1fr; + grid-template-areas: "head" "body" "hint"; + } + + &.buttony tab-body { + display: grid; + grid-template-columns: 1fr 4rem; + + input[type="text"] { border-radius: 0.25rem 0 0 0.25rem !important; } + input[disabled] { cursor: not-allowed; } + a.st-button { + border-radius: 0 0.25rem 0.25rem 0 !important; + justify-content: center; + } + } + } +} + +tab-submit { + position: sticky; + bottom: 0; + + background: themed("color-section"); + border-radius: 0.25rem; + width: min-content; + margin-top: 0.5rem; +} + +@include window-larger-than(50rem) { + tabs-menu { + input[name="search"] { + width: unset; + border-radius: 0.25rem 0.25rem 0.25rem 0; + border-bottom: 2px solid themed("color-section"); + } + } + + tabs-content { + tab-entry { + grid-template-areas: "head body" ". hint"; + grid-template-columns: 1fr 1fr; + &.inline { + grid-template-areas: "head hint body"; + grid-template-columns: 8fr 7fr 1fr; + } + } + } +} diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 0478d5f6e..533c61cea 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -1,137 +1,36 @@ -
<%= link_to "Request a name change", new_user_name_change_request_path %>
-- <% if CurrentUser.user.email.present? %> - <%= CurrentUser.user.email %> - <% else %> - blank - <% end %> - – - <%= link_to "Change your email", new_maintenance_user_email_change_path %> -
-