From 2276c33f2b3af948ca55f5226588cbb07c1f47ad Mon Sep 17 00:00:00 2001 From: MareStare Date: Sun, 3 May 2026 17:43:21 +0000 Subject: [PATCH 1/2] Preserve settings page tab state on refresh and back/forward page navigation0 --- assets/js/boorujs.ts | 84 ++++++++++++------- .../templates/setting/edit.html.slime | 32 +++---- lib/philomena_web/views/setting_view.ex | 33 ++++++-- 3 files changed, 97 insertions(+), 52 deletions(-) diff --git a/assets/js/boorujs.ts b/assets/js/boorujs.ts index 41a18ec6d..37e3eac52 100644 --- a/assets/js/boorujs.ts +++ b/assets/js/boorujs.ts @@ -4,7 +4,7 @@ * Apply event-based actions through data-* attributes. The attributes are structured like so: [data-event-action] */ -import { assertType } from './utils/assert'; +import { assertNotUndefined, assertType } from './utils/assert'; import { $, $$ } from './utils/dom'; import { fetchHtml, handleError } from './utils/requests'; import { showBlock } from './utils/image'; @@ -23,7 +23,6 @@ declare global { type EventType = 'click' | 'change' | 'fetchcomplete'; interface ActionData { - attr: string; el: HTMLElement; value: string; base?: ParentNode; @@ -143,35 +142,46 @@ const actions: Record = { }, tab(data) { - const block = data.el.parentNode?.parentNode; - if (!(block instanceof HTMLElement)) return; - - const newTab = $(`.block__tab[data-tab="${data.value}"]`, block); - const loadTab = data.el.dataset.loadTab; - - // Switch tab - const selectedTab = $('.selected', block); - if (selectedTab) { - selectedTab.classList.remove('selected'); - } - data.el.classList.add('selected'); - - // Switch contents - actions.tabHide({ ...data, base: block, value: '.block__tab' }); - actions.show({ ...data, base: block, value: `.block__tab[data-tab="${data.value}"]` }); - - // If the tab has a 'data-load-tab' attribute, load and insert the content - if (loadTab && newTab && !newTab.dataset.loaded) { - fetchHtml(loadTab) - .then(handleError) - .then(response => response.text()) - .then(response => (newTab.innerHTML = response)) - .then(() => (newTab.dataset.loaded = 'true')) - .catch(() => (newTab.textContent = 'Error!')); - } + switchTab(data); }, }; +function switchTab(data: ActionData & { noPushState?: boolean }) { + const block = data.el.parentNode?.parentNode; + if (!(block instanceof HTMLElement)) return; + + const newTab = $(`.block__tab[data-tab="${data.value}"]`, block); + const loadTab = data.el.dataset.loadTab; + + // Switch tab + const selectedTab = $('.selected', block); + if (selectedTab) { + selectedTab.classList.remove('selected'); + } + data.el.classList.add('selected'); + + // Switch contents + actions.tabHide({ ...data, base: block, value: '.block__tab' }); + actions.show({ ...data, base: block, value: `.block__tab[data-tab="${data.value}"]` }); + + if (!data.noPushState && block.dataset.tabPersistInQueryParams === 'true') { + // Save navigation state in the URL query params + const url = new URL(window.location.href); + url.searchParams.set('tab', data.value); + window.history.pushState({}, '', url); + } + + // If the tab has a 'data-load-tab' attribute, load and insert the content + if (loadTab && newTab && !newTab.dataset.loaded) { + fetchHtml(loadTab) + .then(handleError) + .then(response => response.text()) + .then(response => (newTab.innerHTML = response)) + .then(() => (newTab.dataset.loaded = 'true')) + .catch(() => (newTab.textContent = 'Error!')); + } +} + // Use this function to apply a callback to elements matching the selectors function selectorCb(base: ParentNode = document, selector: string, cb: (el: Element) => void) { $$(selector, base).forEach(cb); @@ -197,7 +207,7 @@ function matchAttributes(event: Event) { const value = el?.getAttribute(attr) || ''; if (el && value) { - actions[action]({ attr, el, value }); + actions[action]({ el, value }); event.preventDefault(); } } @@ -208,4 +218,20 @@ export function registerEvents() { for (const type in types) { document.addEventListener(type, matchAttributes); } + + window.addEventListener('popstate', () => { + const url = new URL(window.location.href); + const tab = url.searchParams.get('tab'); + const tabs = $$(`[data-click-tab]`); + const explicitTab = tab && tabs.find(btn => btn.dataset.clickTab === tab); + const tabBtn = explicitTab || tabs.find(btn => btn.dataset.hasOwnProperty('tabDefault')); + if (tabBtn) { + switchTab({ + el: tabBtn, + value: assertNotUndefined(tabBtn.dataset.clickTab), + // We don't need to push a new state on back/forward navigation, only switch the tab + noPushState: true, + }); + } + }); } diff --git a/lib/philomena_web/templates/setting/edit.html.slime b/lib/philomena_web/templates/setting/edit.html.slime index 17c8e9f82..ea3e901fb 100644 --- a/lib/philomena_web/templates/setting/edit.html.slime +++ b/lib/philomena_web/templates/setting/edit.html.slime @@ -4,21 +4,21 @@ h1 Content Settings .alert.alert-danger p Oops, something went wrong! Please check the errors below. - #js-setting-table.block + #js-setting-table.block data-tab-persist-in-query-params="true" .block__header.block__header--js-tabbed = if @conn.assigns.current_user do - = link "Watch List", to: "#", class: "selected", data: [click_tab: "watched"] - = link "Display", to: "#", data: [click_tab: "display"] - = link "Comments", to: "#", data: [click_tab: "comments"] - = link "Notifications", to: "#", data: [click_tab: "notifications"] - = link "Metadata", to: "#", data: [click_tab: "metadata"] - = link "Local", to: "#", data: [click_tab: "local"] + = tab_link(@conn, "Watch List", "watched", default: true) + = tab_link(@conn, "Display", "display") + = tab_link(@conn, "Comments", "comments") + = tab_link(@conn, "Notifications", "notifications") + = tab_link(@conn, "Metadata", "metadata") + = tab_link(@conn, "Local", "local") - else - = link "Local", to: "#", class: "selected", data: [click_tab: "local"] - = link "More settings", to: "#", data: [click_tab: "join-the-herd"] + = tab_link(@conn, "Local", "local", default: true) + = tab_link(@conn, "More settings", "join-the-herd") = if @conn.assigns.current_user do - .block__tab data-tab="watched" + .block__tab class=tab_class(@conn, "watched", default: true) data-tab="watched" h4 Tags .field = label f, :watched_tag_list, "Tags to watch" @@ -55,7 +55,7 @@ h1 Content Settings br ' Do not share this URL with anyone, it may allow an attacker to compromise your account. - .block__tab.hidden data-tab="display" + .block__tab class=tab_class(@conn, "display") data-tab="display" = field_with_help( \ "Align content to the center of the page - try this option out if you " <> \ "browse the site on a tablet or a fairly wide screen.", @@ -134,7 +134,7 @@ h1 Content Settings ] \ ) - .block__tab.hidden data-tab="comments" + .block__tab class=tab_class(@conn, "comments") data-tab="comments" = field_with_help( \ "Display the newest comments at the top of the page.", \ [ \ @@ -174,7 +174,7 @@ h1 Content Settings ] \ ) - .block__tab.hidden data-tab="notifications" + .block__tab class=tab_class(@conn, "notifications") data-tab="notifications" = field_with_help( \ "If enabled, you'll be subscribed to things (images or topics) " <> \ "automatically as soon as you post a comment or reply, keeping " <> \ @@ -203,7 +203,7 @@ h1 Content Settings ] \ ) - .block__tab.hidden data-tab="metadata" + .block__tab class=tab_class(@conn, "metadata") data-tab="metadata" .field => checkbox f, :fancy_tag_field_on_upload, class: "checkbox" => label f, :fancy_tag_field_on_upload, "Fancy tags - uploads" @@ -229,7 +229,7 @@ h1 Content Settings ] \ ) - .block__tab class=local_tab_class(@conn) data-tab="local" + .block__tab class=tab_class(@conn, "local", default: is_nil(@conn.assigns.current_user)) data-tab="local" .block.block--fixed.block--warning Settings on this tab are saved in the current browser. They are independent of your login. = field_with_help( \ "Use high quality thumbnails on displays with a high pixel density. " <> \ @@ -347,7 +347,7 @@ h1 Content Settings ) = if !@conn.assigns.current_user do - .block__tab.hidden data-tab="join-the-herd" + .block__tab class=tab_class(@conn, "join-the-herd") data-tab="join-the-herd" p ' Consider => link "creating an account!", to: ~p"/registrations/new" diff --git a/lib/philomena_web/views/setting_view.ex b/lib/philomena_web/views/setting_view.ex index 13f0398ea..1d98bf812 100644 --- a/lib/philomena_web/views/setting_view.ex +++ b/lib/philomena_web/views/setting_view.ex @@ -29,16 +29,35 @@ defmodule PhilomenaWeb.SettingView do ] end - def local_tab_class(conn) do - case conn.assigns.current_user do - nil -> "" - _user -> "hidden" - end - end - def staff?(%{role: role}), do: role != "user" def staff?(_), do: false + def tab_class(conn, tab_id, opts \\ []) do + if is_active_tab(conn, tab_id, opts), do: "", else: "hidden" + end + + def tab_link(conn, display_name, tab_id, opts \\ []) do + default = Keyword.get(opts, :default, false) + class = if is_active_tab(conn, tab_id, opts), do: "selected", else: "" + + link(display_name, + to: "?tab=#{tab_id}", + data: [click_tab: tab_id, tab_default: default], + class: class + ) + end + + defp is_active_tab(conn, tab_id, opts) do + default = Keyword.get(opts, :default, false) + tab = conn.params["tab"] + + if is_nil(tab) do + default + else + tab == tab_id + end + end + def field_with_help(title, children) do content = children From 92d02bcf572a4ad7c9e7fe9cdccb43e2b116a8c7 Mon Sep 17 00:00:00 2001 From: MareStare Date: Mon, 4 May 2026 13:26:03 +0000 Subject: [PATCH 2/2] Point-free notation --- assets/js/boorujs.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/assets/js/boorujs.ts b/assets/js/boorujs.ts index 37e3eac52..ca424a80c 100644 --- a/assets/js/boorujs.ts +++ b/assets/js/boorujs.ts @@ -141,9 +141,7 @@ const actions: Record = { } }, - tab(data) { - switchTab(data); - }, + tab: switchTab, }; function switchTab(data: ActionData & { noPushState?: boolean }) {