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

Merge pull request #18690 from overleaf/ii-bs5-projects-welcome

[web] Welcome page migration

GitOrigin-RevId: 2469786372df24d579d1987cf5bb1113450e9d78
ilkin-overleaf 2 лет назад
Родитель
Сommit
182e9859ec
33 измененных файлов с 721 добавлено и 260 удалено
  1. 1 1
      services/web/frontend/js/features/history/components/change-list/owner-paywall-prompt.tsx
  2. 4 2
      services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx
  3. 3 3
      services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx
  4. 3 3
      services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx
  5. 27 21
      services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx
  6. 18 16
      services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx
  7. 14 10
      services/web/frontend/js/features/project-list/components/project-list-root.tsx
  8. 206 74
      services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-create-new-project-dropdown.tsx
  9. 25 22
      services/web/frontend/js/features/project-list/components/welcome-message.tsx
  10. 2 12
      services/web/frontend/js/features/settings/components/emails/add-email.tsx
  11. 2 14
      services/web/frontend/js/features/settings/components/emails/row.tsx
  12. 1 1
      services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx
  13. 6 0
      services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx
  14. 22 0
      services/web/frontend/js/features/ui/components/bootstrap-5/table.tsx
  15. 1 0
      services/web/frontend/js/features/ui/components/ol/ol-button.tsx
  16. 27 0
      services/web/frontend/js/features/ui/components/ol/ol-form.tsx
  17. 1 0
      services/web/frontend/js/features/ui/components/ol/ol-modal.tsx
  18. 29 0
      services/web/frontend/js/features/ui/components/ol/ol-table.tsx
  19. 11 3
      services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts
  20. 6 6
      services/web/frontend/js/shared/components/start-free-trial-button.tsx
  21. 4 1
      services/web/frontend/stories/start-free-trial-button.stories.jsx
  22. 27 0
      services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss
  23. 1 0
      services/web/frontend/stylesheets/bootstrap-5/base/bootstrap.scss
  24. 16 0
      services/web/frontend/stylesheets/bootstrap-5/base/layout.scss
  25. 1 0
      services/web/frontend/stylesheets/bootstrap-5/components/all.scss
  26. 2 1
      services/web/frontend/stylesheets/bootstrap-5/components/button.scss
  27. 6 0
      services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
  28. 74 0
      services/web/frontend/stylesheets/bootstrap-5/components/table.scss
  29. 1 1
      services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
  30. 0 65
      services/web/frontend/stylesheets/bootstrap-5/pages/project-list-react.scss
  31. 170 0
      services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss
  32. 6 3
      services/web/frontend/stylesheets/core/type.less
  33. 4 1
      services/web/test/frontend/components/shared/start-free-trial-button.spec.tsx

+ 1 - 1
services/web/frontend/js/features/history/components/change-list/owner-paywall-prompt.tsx

@@ -51,7 +51,7 @@ export function OwnerPaywallPrompt() {
       <p>
         <StartFreeTrialButton
           source="history"
-          buttonProps={{ bsStyle: 'default', className: 'btn-premium' }}
+          buttonProps={{ variant: 'premium' }}
           handleClick={handleFreeTrialClick}
         >
           {hasNewPaywallCta

+ 4 - 2
services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx

@@ -84,9 +84,11 @@ const CompileTimeout = memo(function CompileTimeout({
                 <StartFreeTrialButton
                   source="compile-timeout"
                   buttonProps={{
-                    bsStyle: 'success',
+                    variant: 'primary',
                     className: 'row-spaced-small',
-                    block: true,
+                    bs3Props: {
+                      block: true,
+                    },
                   }}
                 >
                   {hasNewPaywallCta

+ 3 - 3
services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx

@@ -1,5 +1,5 @@
-import AccessibleModal from '../../../../shared/components/accessible-modal'
 import ModalContentNewProjectForm from './modal-content-new-project-form'
+import OLModal from '@/features/ui/components/ol/ol-modal'
 
 type BlankProjectModalProps = {
   onHide: () => void
@@ -7,7 +7,7 @@ type BlankProjectModalProps = {
 
 function BlankProjectModal({ onHide }: BlankProjectModalProps) {
   return (
-    <AccessibleModal
+    <OLModal
       show
       animation
       onHide={onHide}
@@ -15,7 +15,7 @@ function BlankProjectModal({ onHide }: BlankProjectModalProps) {
       backdrop="static"
     >
       <ModalContentNewProjectForm onCancel={onHide} />
-    </AccessibleModal>
+    </OLModal>
   )
 }
 

+ 3 - 3
services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx

@@ -1,4 +1,4 @@
-import AccessibleModal from '../../../../shared/components/accessible-modal'
+import OLModal from '@/features/ui/components/ol/ol-modal'
 import ModalContentNewProjectForm from './modal-content-new-project-form'
 
 type ExampleProjectModalProps = {
@@ -7,7 +7,7 @@ type ExampleProjectModalProps = {
 
 function ExampleProjectModal({ onHide }: ExampleProjectModalProps) {
   return (
-    <AccessibleModal
+    <OLModal
       show
       animation
       onHide={onHide}
@@ -15,7 +15,7 @@ function ExampleProjectModal({ onHide }: ExampleProjectModalProps) {
       backdrop="static"
     >
       <ModalContentNewProjectForm onCancel={onHide} template="example" />
-    </AccessibleModal>
+    </OLModal>
   )
 }
 

+ 27 - 21
services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx

@@ -1,5 +1,5 @@
 import React, { useState } from 'react'
-import { Alert, Button, Form, FormControl, Modal } from 'react-bootstrap'
+import { Alert } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import useAsync from '../../../../shared/hooks/use-async'
 import {
@@ -10,6 +10,15 @@ import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-
 import { useLocation } from '../../../../shared/hooks/use-location'
 import getMeta from '@/utils/meta'
 import Notification from '@/shared/components/notification'
+import {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLForm from '@/features/ui/components/ol/ol-form'
 
 type NewProjectData = {
   project_id: string
@@ -56,24 +65,22 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
       .catch(() => {})
   }
 
-  const handleChangeName = (
-    e: React.ChangeEvent<HTMLInputElement & FormControl>
-  ) => {
+  const handleChangeName = (e: React.ChangeEvent<HTMLInputElement>) => {
     setProjectName(e.currentTarget.value)
   }
 
-  const handleSubmit = (e: React.FormEvent<Form>) => {
+  const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault()
     createNewProject()
   }
 
   return (
     <>
-      <Modal.Header closeButton>
-        <Modal.Title>{t('new_project')}</Modal.Title>
-      </Modal.Header>
+      <OLModalHeader closeButton>
+        <OLModalTitle>{t('new_project')}</OLModalTitle>
+      </OLModalHeader>
 
-      <Modal.Body>
+      <OLModalBody>
         {isError &&
           (newNotificationStyle ? (
             <div className="notification-list">
@@ -85,30 +92,29 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
           ) : (
             <Alert bsStyle="danger">{getUserFacingMessage(error)}</Alert>
           ))}
-        <Form onSubmit={handleSubmit}>
-          <input
+        <OLForm onSubmit={handleSubmit}>
+          <OLFormControl
             type="text"
-            className="form-control"
             ref={autoFocusedRef}
             placeholder={t('project_name')}
             onChange={handleChangeName}
             value={projectName}
           />
-        </Form>
-      </Modal.Body>
+        </OLForm>
+      </OLModalBody>
 
-      <Modal.Footer>
-        <Button bsStyle={null} className="btn-secondary" onClick={onCancel}>
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={onCancel}>
           {t('cancel')}
-        </Button>
-        <Button
-          bsStyle="primary"
+        </OLButton>
+        <OLButton
+          variant="primary"
           onClick={createNewProject}
           disabled={projectName === '' || isLoading}
         >
           {isLoading ? `${t('creating')}…` : t('create')}
-        </Button>
-      </Modal.Footer>
+        </OLButton>
+      </OLModalFooter>
     </>
   )
 }

+ 18 - 16
services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx

@@ -1,10 +1,15 @@
 import { useEffect, useState } from 'react'
-import { Button, Modal } from 'react-bootstrap'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
 import { useTranslation } from 'react-i18next'
 import Uppy from '@uppy/core'
 import { Dashboard } from '@uppy/react'
 import XHRUpload from '@uppy/xhr-upload'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
 import getMeta from '../../../../utils/meta'
 import { ExposedSettings } from '../../../../../../types/exposed-settings'
 
@@ -85,19 +90,17 @@ function UploadProjectModal({ onHide, openProject }: UploadProjectModalProps) {
   }, [ableToUpload, uppy])
 
   return (
-    <AccessibleModal
+    <OLModal
       show
       animation
       onHide={onHide}
       id="upload-project-modal"
       backdrop="static"
     >
-      <Modal.Header closeButton>
-        <Modal.Title componentClass="h3">
-          {t('upload_zipped_project')}
-        </Modal.Title>
-      </Modal.Header>
-      <Modal.Body>
+      <OLModalHeader closeButton>
+        <OLModalTitle as="h3">{t('upload_zipped_project')}</OLModalTitle>
+      </OLModalHeader>
+      <OLModalBody>
         <Dashboard
           uppy={uppy}
           proudlyDisplayPoweredByUppy={false}
@@ -113,14 +116,13 @@ function UploadProjectModal({ onHide, openProject }: UploadProjectModalProps) {
           }}
           className="project-list-upload-project-modal-uppy-dashboard"
         />
-      </Modal.Body>
-
-      <Modal.Footer>
-        <Button onClick={onHide} bsStyle={null} className="btn-secondary">
+      </OLModalBody>
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={onHide}>
           {t('cancel')}
-        </Button>
-      </Modal.Footer>
-    </AccessibleModal>
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }
 

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

@@ -26,6 +26,7 @@ import { useEffect } from 'react'
 import withErrorBoundary from '../../../infrastructure/error-boundary'
 import { GenericErrorBoundaryFallback } from '../../../shared/components/generic-error-boundary-fallback'
 import { SplitTestProvider } from '@/shared/context/split-test-context'
+import OLCol from '@/features/ui/components/ol/ol-col'
 import { bsVersion } from '@/features/utils/bootstrap-5'
 import classnames from 'classnames'
 
@@ -170,20 +171,18 @@ function ProjectListPageContent() {
           <div className="project-list-welcome-wrapper">
             {error ? <DashApiError /> : ''}
             <Row className="row-spaced mx-0">
-              <Col
-                sm={10}
-                smOffset={1}
-                md={8}
-                mdOffset={2}
+              <OLCol
+                md={{ span: 10, offset: 1 }}
+                lg={{ span: 8, offset: 2 }}
                 className="project-list-empty-col"
               >
                 <Row>
-                  <Col xs={12}>
+                  <OLCol>
                     <UserNotifications />
-                  </Col>
+                  </OLCol>
                 </Row>
                 <WelcomeMessage />
-              </Col>
+              </OLCol>
             </Row>
           </div>
         )}
@@ -196,11 +195,16 @@ function DashApiError() {
   const { t } = useTranslation()
   return (
     <Row className="row-spaced">
-      <Col xs={8} xsOffset={2} aria-live="polite" className="text-center">
+      <OLCol
+        xs={{ span: 8, offset: 2 }}
+        bs3Props={{ xs: 8, xsOffset: 2 }}
+        aria-live="polite"
+        className="text-center"
+      >
         <div className="alert alert-danger">
           {t('generic_something_went_wrong')}
         </div>
-      </Col>
+      </OLCol>
     </Row>
   )
 }

+ 206 - 74
services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-create-new-project-dropdown.tsx

@@ -1,10 +1,56 @@
-import { useCallback, useState } from 'react'
+import { useCallback, useState, forwardRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import type { PortalTemplate } from '../../../../../../types/portal-template'
 import { sendMB } from '../../../../infrastructure/event-tracking'
 import getMeta from '../../../../utils/meta'
 import { NewProjectButtonModalVariant } from '../new-project-button/new-project-button-modal'
 import { ExposedSettings } from '../../../../../../types/exposed-settings'
+import {
+  Dropdown,
+  DropdownDivider,
+  DropdownHeader,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+const CustomDropdownToggle = forwardRef<
+  HTMLButtonElement,
+  React.ComponentProps<'button'>
+>(({ onClick, 'aria-expanded': ariaExpanded }, ref) => {
+  const { t } = useTranslation()
+
+  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
+    e.preventDefault()
+    onClick?.(e)
+
+    sendMB('welcome-page-create-first-project-click', {
+      dropdownMenu: 'main-button',
+      dropdownOpen: ariaExpanded,
+    })
+  }
+
+  return (
+    <button
+      ref={ref}
+      className="card welcome-message-card"
+      onClick={handleClick}
+      id="create-new-project-dropdown-button"
+      aria-expanded={ariaExpanded}
+      aria-haspopup="true"
+    >
+      <span>{t('create_a_new_project')}</span>
+      <img
+        className="welcome-message-card-img"
+        src="/img/welcome-page/create-a-new-project.svg"
+        aria-hidden="true"
+        alt=""
+      />
+    </button>
+  )
+})
+CustomDropdownToggle.displayName = 'CustomDropdownToggle'
 
 type WelcomeMessageCreateNewProjectDropdownProps = {
   setActiveModal: (modal: NewProjectButtonModalVariant) => void
@@ -47,7 +93,7 @@ function WelcomeMessageCreateNewProjectDropdown({
 
   const handleDropdownItemClick = useCallback(
     (
-      e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
+      e: React.MouseEvent,
       modalVariant: NewProjectButtonModalVariant,
       dropdownMenuEvent: string
     ) => {
@@ -66,10 +112,7 @@ function WelcomeMessageCreateNewProjectDropdown({
   )
 
   const handlePortalTemplateClick = useCallback(
-    (
-      e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
-      institutionTemplateName: string
-    ) => {
+    (e: React.MouseEvent, institutionTemplateName: string) => {
       // prevent firing the main dropdown onClick event
       e.stopPropagation()
 
@@ -85,78 +128,167 @@ function WelcomeMessageCreateNewProjectDropdown({
   )
 
   return (
-    <div
-      role="button"
-      tabIndex={0}
-      className="card welcome-message-card"
-      onClick={handleClick}
-      onKeyDown={handleKeyDown}
-    >
-      <p>{t('create_a_new_project')}</p>
-      <img
-        className="welcome-message-card-img"
-        src="/img/welcome-page/create-a-new-project.svg"
-        aria-hidden="true"
-        alt=""
-      />
-      {showDropdown ? (
-        <div className="card create-new-project-dropdown">
-          <button
-            onClick={e =>
-              handleDropdownItemClick(e, 'blank_project', 'blank-project')
-            }
-          >
-            {t('blank_project')}
-          </button>
-          <button
-            onClick={e =>
-              handleDropdownItemClick(e, 'example_project', 'example-project')
-            }
-          >
-            {t('example_project')}
-          </button>
-          <button
-            onClick={e =>
-              handleDropdownItemClick(e, 'upload_project', 'upload-project')
-            }
-          >
-            {t('upload_project')}
-          </button>
-          {isOverleaf && (
-            <button
-              onClick={e =>
-                handleDropdownItemClick(
-                  e,
-                  'import_from_github',
-                  'import-from-github'
-                )
-              }
-            >
-              {t('import_from_github')}
-            </button>
-          )}
-          {(portalTemplates?.length ?? 0) > 0 ? (
-            <>
-              <hr />
-              <div className="dropdown-header">
-                {t('institution_templates')}
-              </div>
-              {portalTemplates?.map((portalTemplate, index) => (
-                <a
-                  key={`portal-template-${index}`}
-                  href={`${portalTemplate.url}#templates`}
+    <BootstrapVersionSwitcher
+      bs3={
+        <div
+          role="button"
+          tabIndex={0}
+          className="card welcome-message-card"
+          onClick={handleClick}
+          onKeyDown={handleKeyDown}
+        >
+          <p>{t('create_a_new_project')}</p>
+          <img
+            className="welcome-message-card-img"
+            src="/img/welcome-page/create-a-new-project.svg"
+            aria-hidden="true"
+            alt=""
+          />
+          {showDropdown && (
+            <div className="card create-new-project-dropdown">
+              <button
+                onClick={e =>
+                  handleDropdownItemClick(e, 'blank_project', 'blank-project')
+                }
+              >
+                {t('blank_project')}
+              </button>
+              <button
+                onClick={e =>
+                  handleDropdownItemClick(
+                    e,
+                    'example_project',
+                    'example-project'
+                  )
+                }
+              >
+                {t('example_project')}
+              </button>
+              <button
+                onClick={e =>
+                  handleDropdownItemClick(e, 'upload_project', 'upload-project')
+                }
+              >
+                {t('upload_project')}
+              </button>
+              {isOverleaf && (
+                <button
                   onClick={e =>
-                    handlePortalTemplateClick(e, portalTemplate.name)
+                    handleDropdownItemClick(
+                      e,
+                      'import_from_github',
+                      'import-from-github'
+                    )
                   }
                 >
-                  {portalTemplate.name}
-                </a>
-              ))}
-            </>
-          ) : null}
+                  {t('import_from_github')}
+                </button>
+              )}
+              {(portalTemplates?.length ?? 0) > 0 ? (
+                <>
+                  <hr />
+                  <div className="dropdown-header">
+                    {t('institution_templates')}
+                  </div>
+                  {portalTemplates?.map((portalTemplate, index) => (
+                    <a
+                      key={`portal-template-${index}`}
+                      href={`${portalTemplate.url}#templates`}
+                      onClick={e =>
+                        handlePortalTemplateClick(e, portalTemplate.name)
+                      }
+                    >
+                      {portalTemplate.name}
+                    </a>
+                  ))}
+                </>
+              ) : null}
+            </div>
+          )}
         </div>
-      ) : null}
-    </div>
+      }
+      bs5={
+        <Dropdown>
+          <DropdownToggle
+            as={CustomDropdownToggle}
+            id="create-new-project-dropdown-toggle-btn"
+          />
+          <DropdownMenu flip={false} className="create-new-project-dropdown">
+            <li role="none">
+              <DropdownItem
+                as="button"
+                onClick={e =>
+                  handleDropdownItemClick(e, 'blank_project', 'blank-project')
+                }
+                tabIndex={-1}
+              >
+                {t('blank_project')}
+              </DropdownItem>
+            </li>
+            <li role="none">
+              <DropdownItem
+                as="button"
+                onClick={e =>
+                  handleDropdownItemClick(
+                    e,
+                    'example_project',
+                    'example-project'
+                  )
+                }
+                tabIndex={-1}
+              >
+                {t('example_project')}
+              </DropdownItem>
+            </li>
+            <li role="none">
+              <DropdownItem
+                as="button"
+                onClick={e =>
+                  handleDropdownItemClick(e, 'upload_project', 'upload-project')
+                }
+                tabIndex={-1}
+              >
+                {t('upload_project')}
+              </DropdownItem>
+            </li>
+            {isOverleaf && (
+              <li role="none">
+                <DropdownItem
+                  as="button"
+                  onClick={e =>
+                    handleDropdownItemClick(
+                      e,
+                      'import_from_github',
+                      'import-from-github'
+                    )
+                  }
+                  tabIndex={-1}
+                >
+                  {t('import_from_github')}
+                </DropdownItem>
+              </li>
+            )}
+            {(portalTemplates?.length ?? 0) > 0 ? (
+              <>
+                <DropdownDivider />
+                <DropdownHeader>{t('institution_templates')}</DropdownHeader>
+                {portalTemplates?.map((portalTemplate, index) => (
+                  <DropdownItem
+                    key={`portal-template-${index}`}
+                    onClick={e =>
+                      handlePortalTemplateClick(e, portalTemplate.name)
+                    }
+                    href={`${portalTemplate.url}#templates`}
+                  >
+                    {portalTemplate.name}
+                  </DropdownItem>
+                ))}
+              </>
+            ) : null}
+          </DropdownMenu>
+        </Dropdown>
+      }
+    />
   )
 }
 

+ 25 - 22
services/web/frontend/js/features/project-list/components/welcome-message.tsx

@@ -7,6 +7,7 @@ import WelcomeMessageLink from './welcome-message-new/welcome-message-link'
 import WelcomeMessageCreateNewProjectDropdown from './welcome-message-new/welcome-message-create-new-project-dropdown'
 import getMeta from '@/utils/meta'
 import { ExposedSettings } from '../../../../../types/exposed-settings'
+import OLCard from '@/features/ui/components/ol/ol-card'
 
 export default function WelcomeMessage() {
   const { t } = useTranslation()
@@ -19,31 +20,33 @@ export default function WelcomeMessage() {
 
   return (
     <>
-      <div className="card welcome-new-wrapper">
-        <div className="welcome text-centered">
-          <h2 className="welcome-title">{t('welcome_to_sl')}</h2>
-          <div className="welcome-message-cards-wrapper">
-            <WelcomeMessageCreateNewProjectDropdown
-              setActiveModal={modal => setActiveModal(modal)}
-            />
-            {wikiEnabled && (
-              <WelcomeMessageLink
-                imgSrc="/img/welcome-page/learn-latex.svg"
-                title="Learn LaTeX with a tutorial"
-                href="/learn/latex/Learn_LaTeX_in_30_minutes"
-                target="_blank"
+      <OLCard>
+        <div className="welcome-new-wrapper">
+          <div className="welcome text-center">
+            <h2 className="welcome-title">{t('welcome_to_sl')}</h2>
+            <div className="welcome-message-cards-wrapper">
+              <WelcomeMessageCreateNewProjectDropdown
+                setActiveModal={modal => setActiveModal(modal)}
               />
-            )}
-            {templatesEnabled && (
-              <WelcomeMessageLink
-                imgSrc="/img/welcome-page/browse-templates.svg"
-                title="Browse templates"
-                href="/templates"
-              />
-            )}
+              {wikiEnabled && (
+                <WelcomeMessageLink
+                  imgSrc="/img/welcome-page/learn-latex.svg"
+                  title="Learn LaTeX with a tutorial"
+                  href="/learn/latex/Learn_LaTeX_in_30_minutes"
+                  target="_blank"
+                />
+              )}
+              {templatesEnabled && (
+                <WelcomeMessageLink
+                  imgSrc="/img/welcome-page/browse-templates.svg"
+                  title="Browse templates"
+                  href="/templates"
+                />
+              )}
+            </div>
           </div>
         </div>
-      </div>
+      </OLCard>
       <NewProjectButtonModal
         modal={activeModal}
         onHide={() => setActiveModal(null)}

+ 2 - 12
services/web/frontend/js/features/settings/components/emails/add-email.tsx

@@ -161,12 +161,7 @@ function AddEmail() {
             </Cell>
           </OLCol>
           <OLCol lg={4}>
-            <Cell
-              className={bsVersion({
-                bs5: 'text-lg-end',
-                bs3: 'text-md-right',
-              })}
-            >
+            <Cell className="text-lg-end">
               <AddNewEmailBtn email={newEmail} disabled />
             </Cell>
           </OLCol>
@@ -206,12 +201,7 @@ function AddEmail() {
         </OLCol>
         {!isSsoAvailableForDomain ? (
           <OLCol lg={4}>
-            <Cell
-              className={bsVersion({
-                bs5: 'text-lg-end',
-                bs3: 'text-md-right',
-              })}
-            >
+            <Cell className="text-lg-end">
               <AddNewEmailBtn
                 email={newEmail}
                 disabled={state.isLoading}

+ 2 - 14
services/web/frontend/js/features/settings/components/emails/row.tsx

@@ -14,7 +14,6 @@ import ReconfirmationInfo from './reconfirmation-info'
 import { useLocation } from '../../../../shared/hooks/use-location'
 import OLRow from '@/features/ui/components/ol/ol-row'
 import OLCol from '@/features/ui/components/ol/ol-col'
-import { bsVersion } from '@/features/utils/bootstrap-5'
 import OLButton from '@/features/ui/components/ol/ol-button'
 
 type EmailsRowProps = {
@@ -43,12 +42,7 @@ function EmailsRow({ userEmailData }: EmailsRowProps) {
           )}
         </OLCol>
         <OLCol lg={3}>
-          <EmailCell
-            className={bsVersion({
-              bs5: 'text-lg-end',
-              bs3: 'text-md-right',
-            })}
-          >
+          <EmailCell className="text-lg-end">
             <Actions userEmailData={userEmailData} />
           </EmailCell>
         </OLCol>
@@ -152,13 +146,7 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
               </p>
             </EmailCell>
           </OLCol>
-          <OLCol
-            lg={3}
-            className={bsVersion({
-              bs5: 'text-lg-end',
-              bs3: 'text-md-right',
-            })}
-          >
+          <OLCol lg={3} className="text-lg-end">
             <EmailCell>
               <OLButton
                 variant="primary"

+ 1 - 1
services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx

@@ -56,7 +56,7 @@ export default function AddCollaboratorsUpgrade() {
       <p className="text-center row-spaced-thin">
         {user.allowedFreeTrial ? (
           <StartFreeTrialButton
-            buttonProps={{ bsStyle: 'success' }}
+            buttonProps={{ variant: 'primary' }}
             handleClick={() => setStartedFreeTrial(true)}
             source="project-sharing"
           >

+ 6 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx

@@ -5,6 +5,7 @@ import {
   DropdownMenu as BS5DropdownMenu,
   DropdownItem as BS5DropdownItem,
   DropdownDivider as BS5DropdownDivider,
+  DropdownHeader as BS5DropdownHeader,
 } from 'react-bootstrap-5'
 import type {
   DropdownProps,
@@ -12,6 +13,7 @@ import type {
   DropdownToggleProps,
   DropdownMenuProps,
   DropdownDividerProps,
+  DropdownHeaderProps,
 } from '@/features/ui/components/types/dropdown-menu-props'
 import MaterialIcon from '@/shared/components/material-icon'
 
@@ -69,3 +71,7 @@ export function DropdownMenu({ as = 'ul', ...props }: DropdownMenuProps) {
 export function DropdownDivider({ as = 'li' }: DropdownDividerProps) {
   return <BS5DropdownDivider as={as} />
 }
+
+export function DropdownHeader({ as = 'li', ...props }: DropdownHeaderProps) {
+  return <BS5DropdownHeader as={as} {...props} />
+}

+ 22 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/table.tsx

@@ -0,0 +1,22 @@
+import { Table as BS5Table } from 'react-bootstrap-5'
+import classnames from 'classnames'
+
+function Table({ responsive, ...rest }: React.ComponentProps<typeof BS5Table>) {
+  const content = (
+    <div
+      className={classnames('table-container', {
+        'table-container-bordered': rest.bordered,
+      })}
+    >
+      <BS5Table {...rest} />
+    </div>
+  )
+
+  if (responsive) {
+    return <div className="table-responsive d-flex">{content}</div>
+  }
+
+  return content
+}
+
+export default Table

+ 1 - 0
services/web/frontend/js/features/ui/components/ol/ol-button.tsx

@@ -11,6 +11,7 @@ export type OLButtonProps = ButtonProps & {
   bs3Props?: {
     loading?: React.ReactNode
     bsSize?: BS3ButtonSize
+    block?: boolean
   }
 }
 

+ 27 - 0
services/web/frontend/js/features/ui/components/ol/ol-form.tsx

@@ -0,0 +1,27 @@
+import { Form } from 'react-bootstrap-5'
+import { Form as BS3Form } from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+type OLFormProps = React.ComponentProps<typeof Form> & {
+  bs3Props?: React.ComponentProps<typeof BS3Form>
+}
+
+function OLForm(props: OLFormProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3FormProps: React.ComponentProps<typeof BS3Form> = {
+    componentClass: rest.as,
+    bsClass: rest.className,
+    children: rest.children,
+    ...bs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3Form {...bs3FormProps} />}
+      bs5={<Form {...rest} />}
+    />
+  )
+}
+
+export default OLForm

+ 1 - 0
services/web/frontend/js/features/ui/components/ol/ol-modal.tsx

@@ -42,6 +42,7 @@ export default function OLModal({ children, ...props }: OLModalProps) {
     onHide: bs5Props.onHide,
     backdrop: bs5Props.backdrop,
     animation: bs5Props.animation,
+    id: bs5Props.id,
     ...bs3Props,
   }
 

+ 29 - 0
services/web/frontend/js/features/ui/components/ol/ol-table.tsx

@@ -0,0 +1,29 @@
+import Table from '@/features/ui/components/bootstrap-5/table'
+import { Table as BS3Table } from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+type OLFormProps = React.ComponentProps<typeof Table> & {
+  bs3Props?: React.ComponentProps<typeof BS3Table>
+}
+
+function OLTable(props: OLFormProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3FormProps: React.ComponentProps<typeof BS3Table> = {
+    bsClass: rest.className,
+    condensed: rest.size === 'sm',
+    children: rest.children,
+    responsive:
+      typeof rest.responsive !== 'string' ? rest.responsive : undefined,
+    ...bs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3Table {...bs3FormProps} />}
+      bs5={<Table {...rest} />}
+    />
+  )
+}
+
+export default OLTable

+ 11 - 3
services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts

@@ -25,27 +25,35 @@ export type DropdownItemProps = PropsWithChildren<{
   eventKey?: string | number
   href?: string
   leadingIcon?: string
-  onClick?: () => void
+  onClick?: React.MouseEventHandler
   trailingIcon?: string
   variant?: 'default' | 'danger'
   className?: string
   role?: string
+  tabIndex?: number
 }>
 
 export type DropdownToggleProps = PropsWithChildren<{
   bsPrefix?: string
   disabled?: boolean
   split?: boolean
-  id: string // necessary for assistive technologies
-  variant: SplitButtonVariants
+  id?: string // necessary for assistive technologies
+  variant?: SplitButtonVariants
+  as?: ElementType
 }>
 
 export type DropdownMenuProps = PropsWithChildren<{
   as?: ElementType
   disabled?: boolean
   show?: boolean
+  className?: string
+  flip?: boolean
 }>
 
 export type DropdownDividerProps = PropsWithChildren<{
   as?: ElementType
 }>
+
+export type DropdownHeaderProps = PropsWithChildren<{
+  as?: ElementType
+}>

+ 6 - 6
services/web/frontend/js/shared/components/start-free-trial-button.tsx

@@ -1,20 +1,20 @@
 import { MouseEventHandler, useCallback, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import { startFreeTrial } from '../../main/account-upgrade'
 import * as eventTracking from '../../infrastructure/event-tracking'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 type StartFreeTrialButtonProps = {
   source: string
   variant?: string
-  buttonProps?: Button.ButtonProps
+  buttonProps?: React.ComponentProps<typeof OLButton>
   children?: React.ReactNode
-  handleClick?: MouseEventHandler<Button>
+  handleClick?: MouseEventHandler<typeof OLButton>
 }
 
 export default function StartFreeTrialButton({
   buttonProps = {
-    bsStyle: 'info',
+    variant: 'secondary',
   },
   children,
   handleClick,
@@ -47,8 +47,8 @@ export default function StartFreeTrialButton({
   )
 
   return (
-    <Button {...buttonProps} onClick={onClick}>
+    <OLButton {...buttonProps} onClick={onClick}>
       {children || t('start_free_trial')}
-    </Button>
+    </OLButton>
   )
 }

+ 4 - 1
services/web/frontend/stories/start-free-trial-button.stories.jsx

@@ -15,7 +15,10 @@ export const ButtonStyle = args => {
   return (
     <StartFreeTrialButton
       {...args}
-      buttonProps={{ bsStyle: 'danger', bsSize: 'lg' }}
+      buttonProps={{
+        variant: 'danger',
+        size: 'large',
+      }}
     />
   )
 }

+ 27 - 0
services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss

@@ -35,6 +35,32 @@ $btn-border-radius-lg: $border-radius-full;
 $btn-border-radius-sm: $border-radius-full;
 $btn-white-space: nowrap;
 
+// Tables
+$table-cell-padding-y: $spacing-04;
+$table-cell-padding-x: $spacing-04;
+$table-cell-padding-y-sm: $spacing-02;
+$table-cell-padding-x-sm: $spacing-02;
+
+$table-color: var(--content-secondary);
+$table-bg: var(--bg-light-primary);
+
+$table-th-font-weight: 600;
+
+$table-striped-color: $table-color;
+$table-striped-bg: var(--bg-light-secondary);
+
+$table-active-color: $table-color;
+$table-active-bg: $bg-accent-03;
+
+$table-hover-color: $table-color;
+$table-hover-bg: var(--bg-light-tertiary);
+
+$table-border-color: $border-divider;
+
+$table-striped-order: even;
+
+$table-caption-color: var(--content-secondary);
+
 // Forms
 
 // form-text-variables
@@ -214,3 +240,4 @@ $dropdown-padding-x: var(--spacing-02);
 $dropdown-padding-y: var(--spacing-02);
 $dropdown-item-padding-x: var(--spacing-04);
 $dropdown-item-padding-y: var(--spacing-05);
+$dropdown-header-color: var(--content-secondary);

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

@@ -27,6 +27,7 @@
 @import 'bootstrap-5/scss/images';
 @import 'bootstrap-5/scss/containers';
 @import 'bootstrap-5/scss/grid';
+@import 'bootstrap-5/scss/tables';
 @import 'bootstrap-5/scss/forms';
 @import 'bootstrap-5/scss/buttons';
 @import 'bootstrap-5/scss/dropdown';

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

@@ -38,3 +38,19 @@ hr {
 .hidden-print {
   @extend .d-print-none;
 }
+
+.row-spaced {
+  margin-top: var(--line-height-03);
+}
+
+.row-spaced-small {
+  margin-top: calc(var(--line-height-03) / 2);
+}
+
+.row-spaced-large {
+  margin-top: calc(var(--line-height-03) * 2);
+}
+
+.row-spaced-extra-large {
+  margin-top: calc(var(--line-height-03) * 4);
+}

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/components/all.scss

@@ -11,3 +11,4 @@
 @import 'footer';
 @import 'nav';
 @import 'navbar';
+@import 'table';

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

@@ -198,7 +198,8 @@
 
 // Set the visited colour for a link that is styled as a button. This is necessary because we have a generic rule that
 // sets the colour of visited links
-a[role='button']:visited {
+a[role='button']:visited,
+a.btn:visited {
   color: var(--bs-btn-color);
 }
 

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

@@ -2,6 +2,12 @@
   display: inline-flex;
 }
 
+.dropdown-header {
+  @include body-xs;
+  padding: var(--spacing-05) var(--spacing-06) var(--spacing-02)
+    var(--spacing-04);
+}
+
 .dropdown-menu {
   @include shadow-md;
 

+ 74 - 0
services/web/frontend/stylesheets/bootstrap-5/components/table.scss

@@ -0,0 +1,74 @@
+.table-container {
+  flex: 1;
+  margin-bottom: var(--spacing-06);
+
+  .table {
+    margin-bottom: initial;
+  }
+}
+
+.table-container-bordered {
+  --table-container-border-width: var(--bs-border-width);
+
+  border-color: $table-border-color;
+  border-radius: var(--border-radius-base);
+  border-width: var(--table-container-border-width);
+  border-style: solid;
+
+  .table {
+    th,
+    td {
+      &:first-child {
+        border-left-width: 0;
+      }
+
+      &:last-child {
+        border-right-width: 0;
+      }
+    }
+
+    tr:first-child {
+      border-top-width: 0;
+
+      th,
+      td {
+        &:first-child {
+          border-top-left-radius: calc(
+            var(--border-radius-base) - var(--table-container-border-width)
+          );
+        }
+      }
+
+      th,
+      td {
+        &:last-child {
+          border-top-right-radius: calc(
+            var(--border-radius-base) - var(--table-container-border-width)
+          );
+        }
+      }
+    }
+
+    tr:last-child {
+      border-bottom-width: 0;
+
+      th,
+      td {
+        &:first-child {
+          border-bottom-left-radius: calc(
+            var(--border-radius-base) - var(--table-container-border-width)
+          );
+        }
+      }
+
+      th,
+      td {
+        &:last-child {
+          border-bottom-right-radius: calc(
+            var(--border-radius-base) - var(--table-container-border-width)
+          );
+        }
+      }
+    }
+  }
+}

+ 1 - 1
services/web/frontend/stylesheets/bootstrap-5/pages/all.scss

@@ -1,2 +1,2 @@
 @import 'account-settings';
-@import 'project-list-react';
+@import 'project-list';

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

@@ -1,65 +0,0 @@
-.project-list-react {
-  body > &.content {
-    padding-top: $header-height;
-    padding-bottom: 0;
-    min-height: calc(100vh - #{$header-height});
-    display: flex;
-    flex-direction: column;
-  }
-
-  .project-list-wrapper {
-    display: flex;
-    align-items: stretch;
-    width: 100%;
-    min-height: calc(100vh - #{$header-height});
-  }
-
-  .project-list-sidebar-wrapper-react {
-    position: relative;
-    background-color: var(--bg-dark-secondary);
-    flex: 0 0 15%;
-    min-height: calc(100vh - #{$header-height});
-    max-width: 320px;
-    min-width: 200px;
-
-    .project-list-sidebar-subwrapper {
-      display: flex;
-      flex-direction: column;
-      height: 100%;
-
-      .project-list-sidebar-react {
-        flex-grow: 1;
-        padding-left: var(--spacing-06);
-        padding-right: var(--spacing-06);
-        -ms-overflow-style: -ms-autohiding-scrollbar;
-        padding-top: var(--spacing-08);
-        padding-bottom: var(--spacing-08);
-        color: var(--neutral-40);
-
-        .small {
-          color: var(--neutral-40);
-        }
-
-        button {
-          white-space: normal;
-          word-wrap: anywhere;
-          // prevents buttons from expanding sidebar width
-        }
-
-        > .dropdown {
-          width: 100%;
-
-          .new-project-button {
-            width: 100%;
-          }
-        }
-      }
-    }
-  }
-
-  .project-list-main-react {
-    flex: 1;
-    overflow-x: hidden;
-    padding: var(--spacing-08) var(--spacing-06);
-  }
-}

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

@@ -0,0 +1,170 @@
+.project-list-empty-col {
+  display: flex;
+  height: 100%;
+  flex-direction: column;
+  flex-wrap: nowrap;
+  .row:first-child {
+    flex-grow: 1; /* fill vertical space so notifications are pushed to bottom */
+  }
+  .card-body {
+    // h2 + .card-thin top padding
+    padding-bottom: calc(var(--line-height-03) + var(--line-height-03) / 2);
+  }
+}
+
+.project-list-react {
+  body > &.content {
+    padding-top: $header-height;
+    padding-bottom: 0;
+    min-height: calc(100vh - #{$header-height});
+    display: flex;
+    flex-direction: column;
+  }
+
+  .project-list-wrapper {
+    display: flex;
+    align-items: stretch;
+    width: 100%;
+    min-height: calc(100vh - #{$header-height});
+  }
+
+  .project-list-sidebar-wrapper-react {
+    position: relative;
+    background-color: var(--bg-dark-secondary);
+    flex: 0 0 15%;
+    min-height: calc(100vh - #{$header-height});
+    max-width: 320px;
+    min-width: 200px;
+
+    .project-list-sidebar-subwrapper {
+      display: flex;
+      flex-direction: column;
+      height: 100%;
+
+      .project-list-sidebar-react {
+        flex-grow: 1;
+        padding-left: var(--spacing-06);
+        padding-right: var(--spacing-06);
+        -ms-overflow-style: -ms-autohiding-scrollbar;
+        padding-top: var(--spacing-08);
+        padding-bottom: var(--spacing-08);
+        color: var(--neutral-40);
+
+        .small {
+          color: var(--neutral-40);
+        }
+
+        button {
+          white-space: normal;
+          word-wrap: anywhere;
+          // prevents buttons from expanding sidebar width
+        }
+
+        > .dropdown {
+          width: 100%;
+
+          .new-project-button {
+            width: 100%;
+          }
+        }
+      }
+    }
+  }
+
+  .project-list-welcome-wrapper {
+    width: 100%;
+    padding-bottom: var(--spacing-08);
+
+    .welcome-new-wrapper {
+      .welcome-title {
+        @include heading-xl();
+        margin-top: var(--spacing-08);
+      }
+
+      .welcome-message-cards-wrapper {
+        display: flex;
+        flex-direction: column;
+        justify-content: space-between;
+        align-items: center;
+        margin-top: var(--spacing-11);
+
+        @include media-breakpoint-up(lg) {
+          flex-direction: row;
+          justify-content: center;
+        }
+      }
+
+      .welcome-message-card {
+        border: 1px solid $bg-light-tertiary;
+        border-radius: $border-radius-large;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: space-between;
+        padding: var(--spacing-08) var(--spacing-06);
+        margin: var(--spacing-05) 0;
+        width: 280px;
+        height: 200px;
+        position: relative;
+        cursor: pointer;
+
+        @include media-breakpoint-up(lg) {
+          margin: 0 var(--spacing-06);
+          height: 240px;
+        }
+
+        &:hover {
+          background-color: $bg-light-secondary;
+        }
+
+        .welcome-message-card-img {
+          @include media-breakpoint-up(lg) {
+            margin-bottom: var(--spacing-07);
+          }
+        }
+      }
+
+      .create-new-project-dropdown {
+        transform: none !important;
+        top: 100% !important;
+        left: var(--spacing-06) !important;
+        right: var(--spacing-06) !important;
+
+        @include media-breakpoint-down(lg) {
+          left: 0 !important;
+          right: 0 !important;
+          margin-top: calc(var(--spacing-05) * -1);
+        }
+      }
+
+      .welcome-message-card-link {
+        &,
+        &:hover {
+          text-decoration: none;
+          color: $neutral-60;
+        }
+      }
+    }
+  }
+
+  .project-list-main-react {
+    flex: 1;
+    overflow-x: hidden;
+    padding: var(--spacing-08) var(--spacing-06);
+  }
+}
+
+.project-list-upload-project-modal-uppy-dashboard .uppy-Root {
+  .uppy-Dashboard-AddFiles-title {
+    display: flex;
+    flex-direction: column;
+    color: var(--neutral-60);
+    white-space: pre-line;
+
+    button.uppy-Dashboard-browse {
+      @extend .btn;
+      @extend .btn-lg;
+      @extend .btn-primary;
+    }
+  }
+}

+ 6 - 3
services/web/frontend/stylesheets/core/type.less

@@ -135,10 +135,12 @@ cite {
 }
 
 // Alignment
-.text-left {
+.text-left,
+.text-start {
   text-align: left;
 }
-.text-right {
+.text-right,
+.text-end {
   text-align: right;
 }
 .text-center {
@@ -151,7 +153,8 @@ cite {
   direction: rtl;
 }
 @media (min-width: @screen-md-min) {
-  .text-md-right {
+  .text-md-right,
+  .text-lg-end {
     text-align: right;
   }
 }

+ 4 - 1
services/web/test/frontend/components/shared/start-free-trial-button.spec.tsx

@@ -38,7 +38,10 @@ describe('start free trial button', function () {
     cy.mount(
       <StartFreeTrialButton
         source="cypress-test"
-        buttonProps={{ bsStyle: 'danger', bsSize: 'lg' }}
+        buttonProps={{
+          variant: 'danger',
+          size: 'large',
+        }}
       />
     )