Files
Modrinth-Enhanced/patches/0005-Make-the-sidebars-foldable.patch

453 lines
17 KiB
Diff

From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 13:06:38 +0200
Subject: [PATCH] Make the sidebars foldable
Three sidebar preferences, all stored as feature flags so they survive a
restart without needing a settings migration:
* The Modrinth Servers button in the left sidebar can be switched off
under Settings > Features > Sidebar. It stays on by default.
* The news section in the right sidebar collapses by clicking its
heading, remembering the state the way the friends list does.
* The button that folds the right sidebar away is always in the title
bar, rather than appearing only once "Hide right sidebar" is turned on
in settings, and what it is set to is remembered. That setting still
works, and still seeds the state. Its label was pointing at the wrong
message ("Next image"), which is fixed here too.
Once the fold state is saved, a check pops onto the button and its
tooltip says it is remembered after a restart, since nothing else shows
that. On pages that need the sidebar (browse, projects, users) the button
stays in place, disabled, instead of vanishing and shifting the title bar.
None of these three reach Modrinth. Preference syncing maps a fixed list
of named fields in both directions, and these are not in it, so they are
neither sent nor overwritten by another device.
The Modrinth Servers switch says so where it sits, because it sits on a
page headed "Sync features across devices" and would otherwise look like
it syncs. The other two are not settings entries - a heading and a title
bar button - so nothing there promises anything.
---
apps/app-frontend/src/App.vue | 180 ++++++++++++++++--
.../ui/settings/display/BehaviorSettings.vue | 18 --
.../ui/settings/display/FeaturesSettings.vue | 28 +++
.../src/composables/use-app-settings.ts | 4 +
packages/app-lib/src/state/settings.rs | 4 +
5 files changed, 201 insertions(+), 33 deletions(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index d750a5f..76da736 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -10,6 +10,8 @@ import {
} from '@modrinth/api-client'
import {
ArrowLeftRightIcon,
+ CheckIcon,
+ ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
@@ -34,6 +36,7 @@ import {
AccountSwitchOverlay,
Admonition,
Avatar,
+ Button,
ButtonLink,
commonMessages,
commonSettingsMessages,
@@ -44,6 +47,7 @@ import {
I18nDebugPanel,
IconButton,
LoadingBar,
+ NewModal,
NewsArticleCard,
NotificationPanel,
PopupNotificationPanel,
@@ -254,12 +258,42 @@ const credentials = ref()
const storedModrinthAccounts = ref([])
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
+// Whether the right sidebar is folded away, remembered across restarts in the
+// feature flags. The title bar button below is the only thing that sets it:
+// upstream's "Hide right sidebar" setting, which used to be what showed that
+// button, is gone.
watch(
- () => appSettings.toggleSidebar,
- (toggleSidebar) => {
- sidebarToggled.value = !toggleSidebar
+ () => appSettings.getFeatureFlag('right_sidebar_collapsed'),
+ (collapsed) => {
+ sidebarToggled.value = !collapsed
},
+ { immediate: true },
)
+
+// Briefly shows a check on the fold button once the state is saved, and the
+// first time also explains that the choice is remembered.
+const sidebarSaved = ref(false)
+let sidebarSavedTimeout
+const sidebarRememberedModal = ref(null)
+
+function setSidebarCollapsed(collapsed) {
+ const explain = !appSettings.featureFlags.sidebar_fold_explained
+ appSettings.featureFlags.right_sidebar_collapsed = collapsed
+ appSettings.featureFlags.sidebar_fold_explained = true
+ getSettings()
+ .then((settings) => {
+ settings.feature_flags.right_sidebar_collapsed = collapsed
+ settings.feature_flags.sidebar_fold_explained = true
+ return setSettings(settings)
+ })
+ .then(() => {
+ sidebarSaved.value = true
+ clearTimeout(sidebarSavedTimeout)
+ sidebarSavedTimeout = setTimeout(() => (sidebarSaved.value = false), 1600)
+ if (explain) sidebarRememberedModal.value?.show()
+ })
+ .catch(handleError)
+}
const forceSidebar = computed(
() =>
route.path.startsWith('/browse') ||
@@ -358,6 +392,21 @@ useQuery({
// ad and the gradient being drawn above it.
const showAd = computed(() => false)
const adConsentAvailable = computed(() => false)
+
+// Whether the news section in the right sidebar is folded away. Kept in the
+// feature flags so it survives a restart, the same way the friends list
+// remembers its collapsed sections.
+const newsCollapsed = computed(() => appSettings.getFeatureFlag('news_collapsed'))
+
+function setNewsCollapsed(collapsed) {
+ appSettings.featureFlags.news_collapsed = collapsed
+ getSettings()
+ .then((settings) => {
+ settings.feature_flags.news_collapsed = collapsed
+ return setSettings(settings)
+ })
+ .catch(handleError)
+}
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: showAd,
@@ -588,6 +637,25 @@ const messages = defineMessages({
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
+ hideSidebar: { id: 'app.navigation.hide-sidebar', defaultMessage: 'Hide sidebar' },
+ showSidebar: { id: 'app.navigation.show-sidebar', defaultMessage: 'Show sidebar' },
+ sidebarRememberedTitle: {
+ id: 'app.navigation.sidebar-remembered.title',
+ defaultMessage: 'The sidebar stays how you leave it',
+ },
+ sidebarRememberedBody: {
+ id: 'app.navigation.sidebar-remembered.body',
+ defaultMessage:
+ 'Folding the sidebar away or bringing it back is saved right away, so the app opens the same way next time, even after a restart. The check on the button shows when it has been saved.',
+ },
+ sidebarRememberedDone: {
+ id: 'app.navigation.sidebar-remembered.done',
+ defaultMessage: 'Done',
+ },
+ sidebarRequired: {
+ id: 'app.navigation.sidebar-required',
+ defaultMessage: 'This page needs the sidebar',
+ },
updateDownloadMissingVersion: {
id: 'app.update.download-error.missing-version',
defaultMessage: 'Failed to download update: no version available',
@@ -2252,6 +2320,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<ImageIcon />
</NavButton>
<NavButton
+ v-if="appSettings.getFeatureFlag('show_hosting_in_sidebar')"
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
@@ -2377,16 +2446,42 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<Breadcrumbs />
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
- <IconButton
- v-if="!forceSidebar && appSettings.toggleSidebar"
- :type="sidebarToggled ? 'base' : 'quiet'"
- :label="formatMessage(messages.nextImage)"
- class="mr-3 transition-transform"
- :class="{ 'rotate-180': !sidebarToggled }"
- @click="sidebarToggled = !sidebarToggled"
+ <!--
+ Stays in place on pages that need the sidebar, so the title bar does
+ not shift. The tooltip sits on the wrapper since a disabled button
+ gets no hover events.
+ -->
+ <span
+ v-tooltip="
+ formatMessage(
+ forceSidebar
+ ? messages.sidebarRequired
+ : sidebarToggled
+ ? messages.hideSidebar
+ : messages.showSidebar,
+ )
+ "
+ class="relative mr-3 flex"
>
- <RightArrowIcon />
- </IconButton>
+ <IconButton
+ :type="sidebarVisible ? 'base' : 'quiet'"
+ :label="formatMessage(sidebarToggled ? messages.hideSidebar : messages.showSidebar)"
+ :disabled="forceSidebar"
+ class="transition-transform"
+ :class="{ 'rotate-180': !sidebarVisible }"
+ @click="setSidebarCollapsed(sidebarToggled)"
+ >
+ <RightArrowIcon />
+ </IconButton>
+ <Transition name="sidebar-saved">
+ <span
+ v-if="sidebarSaved && !forceSidebar"
+ class="sidebar-saved-badge pointer-events-none absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-brand text-brand-inverted"
+ >
+ <CheckIcon class="size-3" stroke-width="3" />
+ </span>
+ </Transition>
+ </span>
<div class="flex mr-3">
<Suspense>
<AppActionBar />
@@ -2502,10 +2597,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
/>
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
- <h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">
+ <button
+ class="button-base m-0 mb-4 flex w-full cursor-pointer items-center justify-between gap-2 border-0 bg-transparent p-0 text-left text-base font-medium text-primary"
+ :aria-expanded="!newsCollapsed"
+ @click="setNewsCollapsed(!newsCollapsed)"
+ >
{{ formatMessage(messages.news) }}
- </h3>
- <div class="space-y-4 flex flex-col items-center w-full">
+ <ChevronDownIcon
+ class="h-5 w-5 shrink-0 transition-transform"
+ :class="{ '-rotate-90': newsCollapsed }"
+ />
+ </button>
+ <div v-if="!newsCollapsed" class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
:key="`news-${index}`"
@@ -2531,6 +2634,21 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<I18nDebugPanel />
<NotificationPanel :has-sidebar="sidebarVisible" />
<PopupNotificationPanel :has-sidebar="sidebarVisible" />
+ <NewModal
+ ref="sidebarRememberedModal"
+ :header="formatMessage(messages.sidebarRememberedTitle)"
+ max-width="460px"
+ >
+ <p class="m-0">{{ formatMessage(messages.sidebarRememberedBody) }}</p>
+ <template #actions>
+ <div class="flex justify-end">
+ <Button type="colored" color="brand" @click="sidebarRememberedModal?.hide()">
+ <CheckIcon />
+ {{ formatMessage(messages.sidebarRememberedDone) }}
+ </Button>
+ </div>
+ </template>
+ </NewModal>
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<MinecraftRequiredModal ref="minecraftRequiredModal" />
@@ -2743,6 +2861,38 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
display: contents;
}
+@media (prefers-reduced-motion: no-preference) {
+ .sidebar-saved-enter-active {
+ transition: transform 0.35s cubic-bezier(0.15, 1.4, 0.64, 0.96);
+ }
+
+ .sidebar-saved-leave-active {
+ transition:
+ transform 0.2s ease,
+ opacity 0.2s ease;
+ }
+
+ .sidebar-saved-enter-from,
+ .sidebar-saved-leave-to {
+ transform: scale(0);
+ opacity: 0;
+ }
+
+ /* The path runs from the long stroke's tip back to the short one, so it is
+ revealed from its end: the check is drawn left to right. */
+ .sidebar-saved-badge :deep(path) {
+ stroke-dasharray: 24;
+ stroke-dashoffset: -24;
+ animation: sidebar-saved-draw 0.3s 0.15s ease-out forwards;
+ }
+}
+
+@keyframes sidebar-saved-draw {
+ to {
+ stroke-dashoffset: 0;
+ }
+}
+
@media (prefers-reduced-motion: no-preference) {
.nav-button-animated-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
diff --git a/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue b/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue
index 55622d7..6c6ba20 100644
--- a/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue
@@ -80,14 +80,6 @@ const messages = defineMessages({
id: 'app.appearance-settings.default-landing-page.library',
defaultMessage: 'Library',
},
- toggleSidebarTitle: {
- id: 'app.appearance-settings.toggle-sidebar.title',
- defaultMessage: 'Hide right sidebar',
- },
- toggleSidebarDescription: {
- id: 'app.appearance-settings.toggle-sidebar.description',
- defaultMessage: 'Hide the right sidebar by default and add a button to show or hide it.',
- },
compactModeTitle: {
id: 'app.appearance-settings.compact-mode.title',
defaultMessage: 'Compact mode',
@@ -288,16 +280,6 @@ onBeforeUnmount(() => {
</div>
<Toggle id="minimize-launcher" v-model="current.minimizeApp" />
</div>
-
- <div class="flex items-center justify-between gap-4">
- <div>
- <h3 class="m-0 text-lg font-semibold text-contrast">
- {{ formatMessage(messages.toggleSidebarTitle) }}
- </h3>
- <p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
- </div>
- <Toggle id="toggle-sidebar" v-model="current.hideRightSidebar" />
- </div>
</div>
</section>
diff --git a/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
index 1d3e379..ae88550 100644
--- a/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
@@ -43,6 +43,7 @@ const quickInstances = useQuickInstanceLimit()
const queryClient = useQueryClient()
const showJumpInFlag: FeatureFlag = 'worlds_in_home'
+const showHostingFlag: FeatureFlag = 'show_hosting_in_sidebar'
const messages = defineMessages({
syncAcrossDevicesTitle: {
@@ -95,6 +96,15 @@ const messages = defineMessages({
id: 'app.features-settings.show-skin-selector.description',
defaultMessage: 'Show a button in the left sidebar to open the skin selector.',
},
+ showHostingTitle: {
+ id: 'app.features-settings.show-hosting.title',
+ defaultMessage: 'Show Modrinth Servers in sidebar',
+ },
+ showHostingDescription: {
+ id: 'app.features-settings.show-hosting.description',
+ defaultMessage:
+ 'Show a button in the left sidebar to manage Modrinth Servers. Added by Modrinth Enhanced, so it stays on this device and is not synced to your Modrinth account.',
+ },
quickInstancesTitle: {
id: 'app.features-settings.quick-instances.title',
defaultMessage: 'Quick instances in sidebar',
@@ -125,6 +135,7 @@ type FeaturesSettingsState = {
showScreenshotsTab: boolean
showAllScreenshots: boolean
showSkinSelector: boolean
+ showHosting: boolean
quickInstanceCount: number
showJumpIn: boolean
}
@@ -144,6 +155,7 @@ function getFeaturesSettingsState(
showScreenshotsTab: settings.show_screenshots_tab_in_instances,
showAllScreenshots: globalSyncedOptions.screenshots,
showSkinSelector: settings.show_skin_selector_in_sidebar,
+ showHosting: settings.feature_flags[showHostingFlag] ?? DEFAULT_FEATURE_FLAGS[showHostingFlag],
quickInstanceCount: quickInstances.limit.value ?? QUICK_INSTANCE_LIMIT_MAX,
showJumpIn: settings.feature_flags[showJumpInFlag] ?? DEFAULT_FEATURE_FLAGS[showJumpInFlag],
}
@@ -184,6 +196,7 @@ const settingsMutation = useMutation({
feature_flags: {
...latestSettings.feature_flags,
[showJumpInFlag]: value.showJumpIn,
+ [showHostingFlag]: value.showHosting,
},
}
@@ -206,6 +219,7 @@ const settingsMutation = useMutation({
appSettings.showScreenshotsTabInInstances = value.showScreenshotsTab
appSettings.showSkinSelectorInSidebar = value.showSkinSelector
appSettings.featureFlags[showJumpInFlag] = value.showJumpIn
+ appSettings.featureFlags[showHostingFlag] = value.showHosting
if (updateQuickInstanceCount) {
quickInstances.setLimit(value.quickInstanceCount)
@@ -366,6 +380,20 @@ onBeforeUnmount(() => {
/>
</div>
+ <div class="flex items-center justify-between gap-4">
+ <div>
+ <h3 class="m-0 text-lg font-semibold text-contrast">
+ {{ formatMessage(messages.showHostingTitle) }}
+ </h3>
+ <p class="m-0 mt-1">{{ formatMessage(messages.showHostingDescription) }}</p>
+ </div>
+ <Toggle
+ id="show-hosting-in-sidebar"
+ v-model="current.showHosting"
+ :aria-label="formatMessage(messages.showHostingTitle)"
+ />
+ </div>
+
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.quickInstancesTitle) }}
diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts
index 0d58903..28c78c3 100644
--- a/apps/app-frontend/src/composables/use-app-settings.ts
+++ b/apps/app-frontend/src/composables/use-app-settings.ts
@@ -24,6 +24,10 @@ export const DEFAULT_FEATURE_FLAGS = {
friends_pending_collapsed: true,
dismissed_photosensitivity_filter_warning: false,
localhost_sign_in: false,
+ show_hosting_in_sidebar: true,
+ news_collapsed: false,
+ sidebar_fold_explained: false,
+ right_sidebar_collapsed: false,
}
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index 20601b8..b5f2ec6 100644
--- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs
@@ -88,6 +88,10 @@ pub enum FeatureFlag {
FriendsPendingCollapsed,
DismissedPhotosensitivityFilterWarning,
LocalhostSignIn,
+ ShowHostingInSidebar,
+ NewsCollapsed,
+ SidebarFoldExplained,
+ RightSidebarCollapsed,
}
impl Settings {