Просмотр исходного кода

Merge pull request #22222 from overleaf/td-thin-footer-react

Implement thin footer in Bootstrap 5

GitOrigin-RevId: c45f63b3d2536e6266eb55cc95df11b53a6b77ec
Tim Down 1 год назад
Родитель
Сommit
79e4b76551
33 измененных файлов с 292 добавлено и 145 удалено
  1. 1 2
      server-ce/test/learn-wiki.spec.ts
  2. 2 2
      services/web/app/src/infrastructure/ExpressLocals.js
  3. 4 1
      services/web/app/views/layout-marketing.pug
  4. 9 2
      services/web/app/views/layout-react.pug
  5. 1 1
      services/web/app/views/layout-website-redesign-bootstrap-5.pug
  6. 1 1
      services/web/app/views/layout-website-redesign.pug
  7. 1 1
      services/web/app/views/layout/fat-footer-react-bootstrap-5.pug
  8. 6 8
      services/web/app/views/layout/language-picker-bootstrap-5.pug
  9. 38 0
      services/web/app/views/layout/thin-footer-bootstrap-5.pug
  10. 0 0
      services/web/app/views/layout/thin-footer.pug
  11. 1 0
      services/web/frontend/extracted-translations.json
  12. 3 3
      services/web/frontend/js/features/header-footer-react/index.tsx
  13. 1 2
      services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx
  14. 2 2
      services/web/frontend/js/features/project-list/components/project-list-root.tsx
  15. 1 1
      services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer-base.tsx
  16. 1 2
      services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer.tsx
  17. 9 0
      services/web/frontend/js/features/ui/components/bootstrap-5/footer/footer.tsx
  18. 90 0
      services/web/frontend/js/features/ui/components/bootstrap-5/footer/thin-footer.tsx
  19. 9 8
      services/web/frontend/js/features/ui/components/bootstrap-5/language-picker.tsx
  20. 0 7
      services/web/frontend/js/features/ui/components/types/fat-footer-metadata.ts
  21. 18 0
      services/web/frontend/js/features/ui/components/types/footer-metadata.ts
  22. 2 2
      services/web/frontend/js/utils/meta.ts
  23. 7 3
      services/web/frontend/stylesheets/bootstrap-5/abstracts/variables.scss
  24. 8 0
      services/web/frontend/stylesheets/bootstrap-5/base/layout.scss
  25. 2 0
      services/web/frontend/stylesheets/bootstrap-5/base/links.scss
  26. 2 2
      services/web/frontend/stylesheets/bootstrap-5/components/button.scss
  27. 6 1
      services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
  28. 51 75
      services/web/frontend/stylesheets/bootstrap-5/components/footer.scss
  29. 10 4
      services/web/frontend/stylesheets/bootstrap-5/pages/project-list-default.scss
  30. 0 12
      services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss
  31. 4 3
      services/web/test/frontend/components/shared/language-picker.spec.tsx
  32. 1 0
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  33. 1 0
      services/web/test/frontend/features/project-list/components/table/project-tools/project-tools-rename.test.tsx

+ 1 - 2
server-ce/test/learn-wiki.spec.ts

@@ -83,8 +83,7 @@ describe('LearnWiki', function () {
   })
 
   function checkDisabled() {
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('should not add a documentation entry to the nav bar', () => {
+    it('should not add a documentation entry to the nav bar', () => {
       login(REGULAR_USER)
       cy.visit('/project')
       cy.findByText('Documentation').should('not.exist')

+ 2 - 2
services/web/app/src/infrastructure/ExpressLocals.js

@@ -244,8 +244,8 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
     }
 
     // This function is used to add translations from the server for main
-    // navigation items because it's tricky to get them in the front end
-    // otherwise.
+    // navigation and footer items because it's tricky to get them in the front
+    // end otherwise.
     res.locals.cloneAndTranslateText = obj => {
       const clone = _.cloneDeep(obj)
       addTranslatedTextDeep(clone)

+ 4 - 1
services/web/app/views/layout-marketing.pug

@@ -17,7 +17,10 @@ block body
 
 	if (typeof suppressFooter === "undefined")
 		if showThinFooter
-			include layout/footer-marketing
+			if bootstrapVersion === 5
+				include layout/thin-footer-bootstrap-5
+			else
+				include layout/thin-footer
 		else
 			include layout/fat-footer
 

+ 9 - 2
services/web/app/views/layout-react.pug

@@ -40,8 +40,12 @@ block append meta
 			items: cloneAndTranslateText(nav.header_extras)
 		})
 		meta(name="ol-footer" data-type="json" content={
+			showThinFooter: showThinFooter,
+			showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by,
 			subdomainLang: settings.i18n.subdomainLang,
-			translatedLanguages: settings.translatedLanguages
+			translatedLanguages: settings.translatedLanguages,
+			leftItems: cloneAndTranslateText(settings.nav.left_footer),
+			rightItems: settings.nav.right_footer
 		})
 
 block body
@@ -55,7 +59,10 @@ block body
 
 	if (typeof suppressFooter === "undefined")
 		if showThinFooter
-			include layout/footer-marketing
+			if bootstrapVersion === 5
+				include layout/thin-footer-bootstrap-5
+			else
+				include layout/thin-footer
 		else
 			if bootstrapVersion === 5
 				include layout/fat-footer-react-bootstrap-5

+ 1 - 1
services/web/app/views/layout-website-redesign-bootstrap-5.pug

@@ -16,7 +16,7 @@ block body
 
 	if (typeof(suppressFooter) == "undefined")
 		if showThinFooter
-			include layout/footer-marketing
+			include layout/thin-footer-bootstrap-5
 		else
 			include layout/fat-footer-website-redesign
 

+ 1 - 1
services/web/app/views/layout-website-redesign.pug

@@ -14,7 +14,7 @@ block body
 
 	if (typeof(suppressFooter) == "undefined")
 		if showThinFooter
-			include layout/footer-marketing
+			include layout/thin-footer
 		else
 			include layout/fat-footer-website-redesign
 

+ 1 - 1
services/web/app/views/layout/fat-footer-react-bootstrap-5.pug

@@ -1 +1 @@
-#fat-footer-container
+#footer-container

+ 6 - 8
services/web/app/views/layout/language-picker-bootstrap-5.pug

@@ -1,21 +1,19 @@
 li.dropdown.dropup.subdued(dropdown).language-picker
-	a#language-picker-toggle(
-		href="#",
+	button#language-picker-toggle.btn.btn-link.btn-inline-link(
 		dropdown-toggle,
 		data-ol-lang-selector-tooltip,
 		data-bs-toggle="dropdown",
-		role="button"
 		aria-haspopup="true",
 		aria-expanded="false",
 		aria-label="Select " + translate('language'),
 		tooltip=translate('language')
 		title=translate('language')
 	)
-		i.fa.fa-fw.fa-language
-		|
-		| #{settings.translatedLanguages[currentLngCode]}
+		span.material-symbols(aria-hidden="true") translate
+		|  
+		span.language-picker-text #{settings.translatedLanguages[currentLngCode]}
 
-	ul.dropdown-menu(role="menu" aria-labelledby="language-picker-toggle")
+	ul.dropdown-menu.dropdown-menu-sm-width(role="menu" aria-labelledby="language-picker-toggle")
 		li.dropdown-header #{translate("language")}
 		each subdomainDetails, subdomain in settings.i18n.subdomainLang
 			if !subdomainDetails.hide
@@ -24,4 +22,4 @@ li.dropdown.dropup.subdued(dropdown).language-picker
 					a.menu-indent(href=subdomainDetails.url+currentUrlWithQueryParams, role="menuitem", class=isActive ? 'dropdown-item active' : 'dropdown-item', aria-selected=isActive ? 'true' : 'false')
 						| #{settings.translatedLanguages[subdomainDetails.lngCode]}
 						if subdomainDetails.lngCode === currentLngCode
-							span.material-symbols.dropdown-item-trailing-icon.pull-right(aria-hidden="true") check
+							span.material-symbols.dropdown-item-trailing-icon(aria-hidden="true") check

+ 38 - 0
services/web/app/views/layout/thin-footer-bootstrap-5.pug

@@ -0,0 +1,38 @@
+footer.site-footer
+	- var showLanguagePicker = Object.keys(settings.i18n.subdomainLang).length > 1
+	- var hasCustomLeftNav = nav.left_footer && nav.left_footer.length > 0
+	.site-footer-content.hidden-print
+		.row
+			ul.site-footer-items.col-lg-9
+				if !settings.nav.hide_powered_by
+					li
+						//- year of Server Pro release, static
+						| © 2024
+						|
+						a(href='https://www.overleaf.com/for/enterprises') Powered by Overleaf
+
+					if showLanguagePicker || hasCustomLeftNav
+						li
+							strong.text-muted |
+
+				if showLanguagePicker
+					include language-picker-bootstrap-5
+
+				if showLanguagePicker && hasCustomLeftNav
+					li
+						strong.text-muted |
+
+				each item in nav.left_footer
+					li
+						if item.url
+							a(href=item.url, class=item.class) !{translate(item.text)}
+						else
+							| !{item.text}
+
+			ul.site-footer-items.col-lg-3.text-end
+				each item in nav.right_footer
+					li
+						if item.url
+							a(href=item.url, class=item.class, aria-label=item.label) !{item.text}
+						else
+							| !{item.text}

+ 0 - 0
services/web/app/views/layout/footer-marketing.pug → services/web/app/views/layout/thin-footer.pug


+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -790,6 +790,7 @@
   "knowledge_base": "",
   "labels_help_you_to_easily_reference_your_figures": "",
   "labels_help_you_to_reference_your_tables": "",
+  "language": "",
   "language_feedback": "",
   "large_or_high-resolution_images_taking_too_long": "",
   "last_active": "",

+ 3 - 3
services/web/frontend/js/features/header-footer-react/index.tsx

@@ -1,7 +1,7 @@
 import ReactDOM from 'react-dom'
 import getMeta from '@/utils/meta'
 import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
-import FatFooter from '@/features/ui/components/bootstrap-5/footer/fat-footer'
+import Footer from '@/features/ui/components/bootstrap-5/footer/footer'
 
 const navbarElement = document.getElementById('navbar-container')
 if (navbarElement) {
@@ -9,8 +9,8 @@ if (navbarElement) {
   ReactDOM.render(<DefaultNavbar {...navbarProps} />, navbarElement)
 }
 
-const footerElement = document.getElementById('fat-footer-container')
+const footerElement = document.getElementById('footer-container')
 if (footerElement) {
   const footerProps = getMeta('ol-footer')
-  ReactDOM.render(<FatFooter {...footerProps} />, footerElement)
+  ReactDOM.render(<Footer {...footerProps} />, footerElement)
 }

+ 1 - 2
services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx

@@ -22,7 +22,6 @@ import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-
 
 export function ProjectListDsNav() {
   const navbarProps = getMeta('ol-navbar')
-  const footerProps = getMeta('ol-footer')
   const { t } = useTranslation()
   const {
     error,
@@ -120,7 +119,7 @@ export function ProjectListDsNav() {
               </div>
             </div>
           </div>
-          <FatFooter {...footerProps} />
+          <FatFooter />
         </div>
       </main>
     </div>

+ 2 - 2
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -17,7 +17,7 @@ import withErrorBoundary from '../../../infrastructure/error-boundary'
 import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
 import getMeta from '@/utils/meta'
 import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
-import FatFooter from '@/features/ui/components/bootstrap-5/footer/fat-footer'
+import Footer from '@/features/ui/components/bootstrap-5/footer/footer'
 import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
 import ProjectListDefault from '@/features/project-list/components/project-list-default'
 import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
@@ -57,7 +57,7 @@ function DefaultNavbarAndFooter({ children }: { children: ReactNode }) {
       >
         {children}
       </main>
-      <FatFooter {...footerProps} />
+      <Footer {...footerProps} />
     </>
   )
 }

+ 1 - 1
services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer-base.tsx

@@ -33,7 +33,7 @@ function FatFooterBase() {
           </FooterBaseLink>
         </div>
         <div className="fat-footer-base-item fat-footer-base-language">
-          <LanguagePicker />
+          <LanguagePicker showHeader={false} />
         </div>
       </div>
       <div className="fat-footer-base-section fat-footer-base-social">

+ 1 - 2
services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer.tsx

@@ -1,7 +1,6 @@
 import React from 'react'
 import { useTranslation } from 'react-i18next'
 import FatFooterBase from './fat-footer-base'
-import { FatFooterMetadata } from '../../types/fat-footer-metadata'
 
 type FooterLinkProps = {
   href: string
@@ -13,7 +12,7 @@ type FooterSectionProps = {
   links: FooterLinkProps[]
 }
 
-function FatFooter(props: FatFooterMetadata) {
+function FatFooter() {
   const { t } = useTranslation()
   const hideFatFooter = false
 

+ 9 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/footer/footer.tsx

@@ -0,0 +1,9 @@
+import { FooterMetadata } from '@/features/ui/components/types/footer-metadata'
+import ThinFooter from '@/features/ui/components/bootstrap-5/footer/thin-footer'
+import FatFooter from '@/features/ui/components/bootstrap-5/footer/fat-footer'
+
+function Footer(props: FooterMetadata) {
+  return props.showThinFooter ? <ThinFooter {...props} /> : <FatFooter />
+}
+
+export default Footer

+ 90 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/footer/thin-footer.tsx

@@ -0,0 +1,90 @@
+import type {
+  FooterItem,
+  FooterMetadata,
+} from '@/features/ui/components/types/footer-metadata'
+import OLRow from '@/features/ui/components/ol/ol-row'
+import LanguagePicker from '@/features/ui/components/bootstrap-5/language-picker'
+import React from 'react'
+
+function FooterItemLi({
+  text,
+  translatedText,
+  url: href,
+  class: className,
+  label,
+}: FooterItem) {
+  const textToDisplay = translatedText || text
+
+  if (!href) {
+    return <li>{textToDisplay}</li>
+  }
+
+  const linkProps = {
+    href,
+    className,
+    'aria-label': label,
+  }
+
+  return (
+    <li>
+      <a {...linkProps} dangerouslySetInnerHTML={{ __html: textToDisplay }} />
+    </li>
+  )
+}
+
+function Separator() {
+  return (
+    <li role="separator" className="text-muted">
+      <strong>|</strong>
+    </li>
+  )
+}
+
+function ThinFooter({
+  showPoweredBy,
+  subdomainLang,
+  leftItems,
+  rightItems,
+}: FooterMetadata) {
+  const showLanguagePicker = Boolean(
+    subdomainLang && Object.keys(subdomainLang).length > 1
+  )
+
+  const hasCustomLeftNav = Boolean(leftItems && leftItems.length > 0)
+
+  return (
+    <footer className="site-footer">
+      <div className="site-footer-content d-print-none">
+        <OLRow>
+          <ul className="site-footer-items col-lg-9">
+            {showPoweredBy ? (
+              <>
+                <li>
+                  {/* year of Server Pro release, static */}© 2024{' '}
+                  <a href="https://www.overleaf.com/for/enterprises">
+                    Powered by Overleaf
+                  </a>
+                </li>
+                {showLanguagePicker || hasCustomLeftNav ? <Separator /> : null}
+              </>
+            ) : null}
+            {showLanguagePicker ? (
+              <li>
+                <LanguagePicker showHeader />
+              </li>
+            ) : null}
+            {hasCustomLeftNav ? <Separator /> : null}
+            {leftItems?.map(item => <FooterItemLi key={item.text} {...item} />)}
+          </ul>
+          <ul className="site-footer-items col-lg-3 text-end">
+            {rightItems?.map(item => (
+              <FooterItemLi key={item.text} {...item} />
+            ))}
+          </ul>
+        </OLRow>
+      </div>
+    </footer>
+  )
+}
+
+export default ThinFooter

+ 9 - 8
services/web/frontend/js/features/ui/components/bootstrap-5/language-picker.tsx

@@ -4,12 +4,13 @@ import {
   DropdownItem,
   DropdownMenu,
   DropdownToggle,
+  DropdownHeader,
 } from './dropdown-menu'
 import { useTranslation } from 'react-i18next'
 import getMeta from '@/utils/meta'
-import Icon from '@/shared/components/icon'
+import MaterialIcon from '@/shared/components/material-icon'
 
-function LanguagePicker() {
+function LanguagePicker({ showHeader } = { showHeader: false }) {
   const { t } = useTranslation()
 
   const currentLangCode = getMeta('ol-i18n').currentLangCode
@@ -26,18 +27,18 @@ function LanguagePicker() {
         className="btn-inline-link"
         variant="link"
       >
-        <Icon
-          type="language"
-          className="fa fa-fw"
-          accessibilityLabel={t('select_a_language')}
-        />
-        {translatedLanguages?.[currentLangCode]}
+        <MaterialIcon type="translate" />
+        &nbsp;
+        <span className="language-picker-text">
+          {translatedLanguages?.[currentLangCode]}
+        </span>
       </DropdownToggle>
 
       <DropdownMenu
         className="dropdown-menu-sm-width"
         aria-labelledby="language-picker-toggle"
       >
+        {showHeader ? <DropdownHeader>{t('language')}</DropdownHeader> : null}
         {subdomainLang &&
           Object.entries(subdomainLang).map(([subdomain, subdomainDetails]) => {
             if (

+ 0 - 7
services/web/frontend/js/features/ui/components/types/fat-footer-metadata.ts

@@ -1,7 +0,0 @@
-import type { SubdomainLang } from '@/features/ui/components/types/fat-footer'
-
-export type FatFooterMetadata = {
-  subdomainLang?: SubdomainLang
-  translatedLanguages: { [key: string]: string }
-  currentLangCode: string
-}

+ 18 - 0
services/web/frontend/js/features/ui/components/types/footer-metadata.ts

@@ -0,0 +1,18 @@
+import type { SubdomainLang } from '@/features/ui/components/types/fat-footer'
+
+export type FooterItem = {
+  text: string
+  translatedText?: string
+  url?: string
+  class?: string
+  label?: string
+}
+
+export type FooterMetadata = {
+  showThinFooter: boolean
+  translatedLanguages: { [key: string]: string }
+  showPoweredBy?: boolean
+  subdomainLang?: SubdomainLang
+  leftItems?: FooterItem[]
+  rightItems?: FooterItem[]
+}

+ 2 - 2
services/web/frontend/js/utils/meta.ts

@@ -49,7 +49,7 @@ import { ThirdPartyIds } from '../../../types/third-party-ids'
 import { Publisher } from '../../../types/subscription/dashboard/publisher'
 import { SubscriptionChangePreview } from '../../../types/subscription/subscription-change-preview'
 import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
-import { FatFooterMetadata } from '@/features/ui/components/types/fat-footer-metadata'
+import { FooterMetadata } from '@/features/ui/components/types/footer-metadata'
 export interface Meta {
   'ol-ExposedSettings': ExposedSettings
   'ol-allInReconfirmNotificationPeriods': UserEmailData[]
@@ -89,7 +89,7 @@ export interface Meta {
   'ol-error': { name: string } | undefined
   'ol-expired': boolean
   'ol-features': Features
-  'ol-footer': FatFooterMetadata
+  'ol-footer': FooterMetadata
   'ol-fromPlansPage': boolean
   'ol-galleryTagName': string
   'ol-gitBridgeEnabled': boolean

+ 7 - 3
services/web/frontend/stylesheets/bootstrap-5/abstracts/variables.scss

@@ -1,10 +1,14 @@
-// Footer
-$footer-height: 50px;
-
 // Header
 $header-height: 68px;
 $toolbar-height: 40px;
 $toolbar-small-height: 32px;
 
+// Footer
+$thin-footer-height: 50px;
+$fat-footer-content-height: calc(100vh - $header-height);
+$thin-footer-content-height: calc(
+  100vh - ($header-height + $thin-footer-height)
+);
+
 // List of allowed themes
 $themes: ('default', 'light');

+ 8 - 0
services/web/frontend/stylesheets/bootstrap-5/base/layout.scss

@@ -1,7 +1,15 @@
+body {
+  position: relative;
+}
+
 .content {
   min-height: 100vh;
   padding-top: $header-height + $spacing-08;
   padding-bottom: $spacing-08;
+
+  .thin-footer & {
+    min-height: calc(100vh - #{$thin-footer-height});
+  }
 }
 
 .content-alt {

+ 2 - 0
services/web/frontend/stylesheets/bootstrap-5/base/links.scss

@@ -8,11 +8,13 @@
   --link-color-dark: var(--link-ui-dark);
   --link-hover-color-dark: var(--link-ui-hover-dark);
   --link-visited-color-dark: var(--link-ui-visited-dark);
+  --link-text-decoration: underline;
   --link-hover-text-decoration: none;
 }
 
 a {
   color: var(--link-color);
+  text-decoration: var(--link-text-decoration);
 
   &:visited {
     color: var(--link-visited-color);

+ 2 - 2
services/web/frontend/stylesheets/bootstrap-5/components/button.scss

@@ -137,7 +137,7 @@
 
 // Make a button look and behave like a link
 .btn-link {
-  color: var(--link-ui);
+  color: var(--link-color);
   font-weight: normal;
   cursor: pointer;
   text-decoration: underline;
@@ -156,7 +156,7 @@
 
   &:hover,
   &:focus {
-    color: var(--link-ui-hover);
+    color: var(--link-hover-color);
     text-decoration: none;
     background-color: transparent;
   }

+ 6 - 1
services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss

@@ -1,3 +1,5 @@
+$dropdown-item-min-height: 36px;
+
 .dropdown {
   display: inline-flex;
 
@@ -20,6 +22,9 @@
 .dropdown-header {
   @include body-xs;
 
+  display: flex;
+  align-items: center;
+  min-height: $dropdown-item-min-height; // a minimum height of 36px to be accessible for touch screens
   padding: var(--spacing-05) var(--spacing-06) var(--spacing-02)
     var(--spacing-04);
 }
@@ -55,7 +60,7 @@
   display: grid;
   grid-auto-flow: column;
   place-content: center start;
-  min-height: 36px; // a minimum height of 36px to be accessible for touch screens
+  min-height: $dropdown-item-min-height; // a minimum height of 36px to be accessible for touch screens
   position: relative;
 
   &:active {

+ 51 - 75
services/web/frontend/stylesheets/bootstrap-5/components/footer.scss

@@ -56,68 +56,82 @@ $footer-width-delta: 320px;
 }
 
 footer.site-footer {
+  --link-color: var(--link-web);
+  --link-hover-color: var(--link-web-hover);
+  --link-visited-color: var(--link-web-visited);
+  --link-text-decoration: none;
+  --link-hover-text-decoration: underline;
+
   background-color: var(--bg-light-primary);
   border-top: 1px solid var(--border-primary-dark);
-  position: absolute;
-  bottom: 0;
-  width: 100%;
 
   @include body-sm;
 
-  height: $footer-height;
+  min-height: $thin-footer-height;
+  line-height: $thin-footer-height - 1; // Hack — in Chrome, using the full $footer-height would generate vertical scrolling
 
-  ul {
+  ul.site-footer-items {
     list-style: none;
-    margin: var(--spacing-00);
+    margin: 0;
 
-    li {
+    > li {
       display: inline-block;
-      margin: var(--spacing-07) var(--spacing-04);
+      margin: 0 var(--spacing-04);
     }
 
     i {
       font-size: var(--font-size-05);
     }
   }
+}
 
-  li.lng-option {
-    text-align: left;
-    display: list-item;
+.site-footer-content {
+  @extend .container-fluid;
+}
 
-    img {
-      vertical-align: text-bottom;
-    }
+.sprite-icon-lang {
+  display: inline-block;
+  vertical-align: middle;
+}
 
-    a:hover,
-    a:focus {
-      color: var(--content-primary-dark);
+#language-picker-toggle {
+  text-decoration: none;
+  cursor: pointer;
 
-      &:visited {
-        color: var(--content-primary-dark);
-      }
-    }
+  &::after {
+    display: none;
   }
 
-  a {
-    color: var(--content-secondary-dark);
-
-    &:hover,
-    &:focus {
-      color: var(--content-secondary-dark);
+  &:hover {
+    .language-picker-text {
+      text-decoration: var(--link-hover-text-decoration);
     }
   }
-}
 
-.site-footer-content {
-  @extend .container-fluid;
+  .material-symbols {
+    vertical-align: -0.1875rem;
+  }
 }
 
-.sprite-icon-lang {
-  display: inline-block;
-  vertical-align: middle;
+.language-picker .dropdown-menu {
+  .dropdown-item {
+    &.active {
+      color: var(--green-70);
+    }
+
+    &:hover {
+      text-decoration: none;
+    }
+  }
 }
 
 .fat-footer {
+  --link-color: var(--content-secondary-dark);
+  --link-hover-color: var(--content-secondary-dark);
+  --link-visited-color: var(--content-secondary-dark);
+  --link-text-decoration: none;
+  --link-hover-text-decoration: underline;
+
   container-name: fatfooter;
   container-type: inline-size;
   background: var(--bg-dark-primary);
@@ -133,20 +147,6 @@ footer.site-footer {
     }
   }
 
-  a {
-    color: var(--content-secondary-dark);
-    text-decoration: none;
-
-    &:hover {
-      color: var(--content-secondary-dark);
-      text-decoration: underline;
-    }
-
-    &:visited {
-      color: var(--content-secondary-dark);
-    }
-  }
-
   .footer-brand-container {
     flex: 1;
   }
@@ -196,16 +196,6 @@ footer.site-footer {
 
     #language-picker-toggle {
       color: var(--content-secondary-dark);
-      cursor: pointer;
-      text-decoration: none;
-
-      &::after {
-        display: none;
-      }
-
-      &:hover {
-        text-decoration: underline;
-      }
     }
 
     .fat-footer-base-meta a:not(.dropdown-toggle) {
@@ -216,16 +206,6 @@ footer.site-footer {
       .dropdown-header {
         display: none; /* hiding rather than removing as still needed in the thin footer */
       }
-
-      .dropdown-item {
-        &.active {
-          color: var(--green-70);
-        }
-
-        &:hover {
-          text-decoration: none;
-        }
-      }
     }
   }
 
@@ -406,17 +386,13 @@ footer.site-footer {
   }
 
   &.fat-footer {
+    --link-color: var(--content-primary);
+    --link-hover-color: var(--content-primary);
+    --link-visited-color: var(--content-primary);
+
     background: var(--content-primary-dark);
     color: var(--content-primary);
 
-    a {
-      color: var(--content-primary);
-
-      &:visited {
-        color: var(--content-primary);
-      }
-    }
-
     .footer-section-heading {
       color: var(--content-primary);
     }

+ 10 - 4
services/web/frontend/stylesheets/bootstrap-5/pages/project-list-default.scss

@@ -1,24 +1,30 @@
 .project-list-react {
+  --project-list-content-height: #{$fat-footer-content-height};
+
   #project-list-root > &.content {
     padding-top: $header-height;
     padding-bottom: 0;
-    min-height: calc(100vh - #{$header-height});
+    min-height: var(--project-list-content-height);
     display: flex;
     flex-direction: column;
+
+    .thin-footer & {
+      --project-list-content-height: #{$thin-footer-content-height};
+    }
   }
 
   .project-list-wrapper {
     display: flex;
     align-items: stretch;
     width: 100%;
-    min-height: calc(100vh - #{$header-height});
+    min-height: var(--project-list-content-height);
   }
 
   .project-list-sidebar-wrapper-react {
     position: relative;
     background-color: var(--bg-dark-secondary);
     flex: 0 0 15%;
-    min-height: calc(100vh - #{$header-height});
+    min-height: var(--project-list-content-height);
     max-width: 320px;
     min-width: 200px;
 
@@ -121,7 +127,7 @@
     flex-direction: column;
     justify-content: center;
     align-items: center;
-    min-height: calc(100vh - #{$header-height});
+    min-height: var(--project-list-content-height);
 
     .loading-screen-brand-container {
       margin: 0 auto;

+ 0 - 12
services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss

@@ -579,18 +579,6 @@
       }
     }
   }
-
-  .loading-container {
-    display: flex;
-    flex-direction: column;
-    justify-content: center;
-    align-items: center;
-    min-height: calc(100vh - #{$header-height});
-
-    .loading-screen-brand-container {
-      margin: 0 auto;
-    }
-  }
 }
 
 .current-plan {

+ 4 - 3
services/web/test/frontend/components/shared/language-picker.spec.tsx

@@ -10,6 +10,7 @@ describe('LanguagePicker', function () {
       currentLangCode: 'en',
     })
     window.metaAttributesCache.set('ol-footer', {
+      showThinFooter: false,
       translatedLanguages: {
         en: 'English',
         fr: 'Français',
@@ -26,12 +27,12 @@ describe('LanguagePicker', function () {
   })
 
   it('renders the language picker with the current language', function () {
-    cy.mount(<LanguagePicker />)
+    cy.mount(<LanguagePicker showHeader />)
     cy.get('#language-picker-toggle').should('contain', 'English')
   })
 
   it('opens the dropdown and lists available languages', function () {
-    cy.mount(<LanguagePicker />)
+    cy.mount(<LanguagePicker showHeader />)
     cy.get('#language-picker-toggle').click()
 
     cy.get('.dropdown-menu').within(() => {
@@ -42,7 +43,7 @@ describe('LanguagePicker', function () {
   })
 
   it('changes the language and updates the URL when a language is selected', function () {
-    cy.mount(<LanguagePicker />)
+    cy.mount(<LanguagePicker showHeader />)
     cy.get('#language-picker-toggle').should('exist').click()
     cy.contains('Français').click()
     cy.url().should('include', 'fr.overleaf.com')

+ 1 - 0
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -55,6 +55,7 @@ describe('<ProjectListRoot />', function () {
     window.metaAttributesCache.set('ol-user', {})
     window.metaAttributesCache.set('ol-user_id', userId)
     window.metaAttributesCache.set('ol-footer', {
+      showThinFooter: false,
       translatedLanguages: { en: 'English' },
       subdomainLang: { en: { lngCode: 'en', url: 'overleaf.com' } },
     })

+ 1 - 0
services/web/test/frontend/features/project-list/components/table/project-tools/project-tools-rename.test.tsx

@@ -65,6 +65,7 @@ describe('<ProjectTools />', function () {
     })
 
     window.metaAttributesCache.set('ol-footer', {
+      showThinFooter: false,
       translatedLanguages: { en: 'English' },
       subdomainLang: { en: { lngCode: 'en', url: 'overleaf.com' } },
     })