mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #18996 from overleaf/td-bs5-nav-react
Main navigation React component GitOrigin-RevId: c99a4b4a2f6fd02618689f829681118b2b64aa8d
This commit is contained in:
parent
2296287e61
commit
d5643d53b3
33 changed files with 669 additions and 15 deletions
|
@ -227,6 +227,26 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
|
||||||
webRouter.use(function (req, res, next) {
|
webRouter.use(function (req, res, next) {
|
||||||
res.locals.translate = req.i18n.translate
|
res.locals.translate = req.i18n.translate
|
||||||
|
|
||||||
|
const addTranslatedTextDeep = obj => {
|
||||||
|
if (_.isObject(obj)) {
|
||||||
|
if (_.has(obj, 'text')) {
|
||||||
|
obj.translatedText = req.i18n.translate(obj.text)
|
||||||
|
}
|
||||||
|
_.forOwn(obj, value => {
|
||||||
|
addTranslatedTextDeep(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
res.locals.cloneAndTranslateText = obj => {
|
||||||
|
const clone = _.cloneDeep(obj)
|
||||||
|
addTranslatedTextDeep(clone)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
// Don't include the query string parameters, otherwise Google
|
// Don't include the query string parameters, otherwise Google
|
||||||
// treats ?nocdn=true as the canonical version
|
// treats ?nocdn=true as the canonical version
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -3,7 +3,7 @@ mixin nav-item
|
||||||
block
|
block
|
||||||
|
|
||||||
mixin nav-link
|
mixin nav-link
|
||||||
a(role="menuitem")&attributes(attributes)
|
a(role="menuitem").nav-link&attributes(attributes)
|
||||||
block
|
block
|
||||||
|
|
||||||
mixin dropdown-menu
|
mixin dropdown-menu
|
||||||
|
|
61
services/web/app/views/layout-react.pug
Normal file
61
services/web/app/views/layout-react.pug
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
//- This is used for pages that are migrated to Bootstrap 5 but don't use Bootstrap's own JS, instead using
|
||||||
|
//- react-bootstrap for all Bootstrap components
|
||||||
|
extends ./layout-base
|
||||||
|
|
||||||
|
include ./_mixins/formMessages
|
||||||
|
include ./_mixins/bootstrap_js
|
||||||
|
|
||||||
|
block entrypointVar
|
||||||
|
- entrypoint = 'marketing'
|
||||||
|
|
||||||
|
block append meta
|
||||||
|
if bootstrapVersion === 5
|
||||||
|
- const canDisplayAdminMenu = hasAdminAccess()
|
||||||
|
- const canDisplayAdminRedirect = canRedirectToAdminDomain()
|
||||||
|
- const sessionUser = getSessionUser()
|
||||||
|
- const staffAccess = sessionUser?.staffAccess
|
||||||
|
- const canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || staffAccess?.splitTestMetrics || staffAccess?.splitTestManagement)
|
||||||
|
- const canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
|
||||||
|
- const enableUpgradeButton = projectDashboardReact && usersBestSubscription && usersBestSubscription.type === 'free'
|
||||||
|
|
||||||
|
meta(name="ol-navbar" data-type="json" content={
|
||||||
|
customLogo: settings.nav.custom_logo,
|
||||||
|
title: nav.title,
|
||||||
|
canDisplayAdminMenu,
|
||||||
|
canDisplayAdminRedirect,
|
||||||
|
canDisplaySplitTestMenu,
|
||||||
|
canDisplaySurveyMenu,
|
||||||
|
enableUpgradeButton,
|
||||||
|
suppressNavbarRight: !!suppressNavbarRight,
|
||||||
|
suppressNavContentLinks: !!suppressNavContentLinks,
|
||||||
|
showSubscriptionLink: nav.showSubscriptionLink,
|
||||||
|
sessionUser: sessionUser ? { email: sessionUser.email} : undefined,
|
||||||
|
adminUrl: settings.adminUrl,
|
||||||
|
items: cloneAndTranslateText(nav.header_extras)
|
||||||
|
})
|
||||||
|
|
||||||
|
block body
|
||||||
|
if (typeof suppressNavbar === "undefined")
|
||||||
|
if bootstrapVersion === 5
|
||||||
|
include layout/navbar-marketing-react-bootstrap-5
|
||||||
|
else
|
||||||
|
include layout/navbar-marketing
|
||||||
|
|
||||||
|
block content
|
||||||
|
|
||||||
|
if (typeof suppressFooter === "undefined")
|
||||||
|
if showThinFooter
|
||||||
|
include layout/footer-marketing
|
||||||
|
else
|
||||||
|
include layout/fat-footer
|
||||||
|
|
||||||
|
if (typeof suppressCookieBanner === "undefined")
|
||||||
|
include _cookie_banner
|
||||||
|
|
||||||
|
if bootstrapVersion === 3
|
||||||
|
!= moduleIncludes("contactModal-marketing", locals)
|
||||||
|
|
||||||
|
block prepend foot-scripts
|
||||||
|
//- Only include Bootstrap JS if using Bootstrap 3
|
||||||
|
if bootstrapVersion === 3
|
||||||
|
+bootstrap-js(3)
|
|
@ -0,0 +1 @@
|
||||||
|
#navbar-container
|
|
@ -1,4 +1,4 @@
|
||||||
extends ../layout-marketing
|
extends ../layout-react
|
||||||
|
|
||||||
block entrypointVar
|
block entrypointVar
|
||||||
- entrypoint = 'pages/project-list'
|
- entrypoint = 'pages/project-list'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
extends ../layout-marketing
|
extends ../layout-react
|
||||||
|
|
||||||
block entrypointVar
|
block entrypointVar
|
||||||
- entrypoint = 'pages/user/settings'
|
- entrypoint = 'pages/user/settings'
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
"1_2_width": "",
|
"1_2_width": "",
|
||||||
"1_4_width": "",
|
"1_4_width": "",
|
||||||
"3_4_width": "",
|
"3_4_width": "",
|
||||||
|
"Account": "",
|
||||||
|
"Account Settings": "",
|
||||||
"a_custom_size_has_been_used_in_the_latex_code": "",
|
"a_custom_size_has_been_used_in_the_latex_code": "",
|
||||||
"a_fatal_compile_error_that_completely_blocks_compilation": "",
|
"a_fatal_compile_error_that_completely_blocks_compilation": "",
|
||||||
"a_file_with_that_name_already_exists_and_will_be_overriden": "",
|
"a_file_with_that_name_already_exists_and_will_be_overriden": "",
|
||||||
|
@ -770,6 +772,7 @@
|
||||||
"log_entry_maximum_entries_title": "",
|
"log_entry_maximum_entries_title": "",
|
||||||
"log_hint_extra_info": "",
|
"log_hint_extra_info": "",
|
||||||
"log_in_with_primary_email_address": "",
|
"log_in_with_primary_email_address": "",
|
||||||
|
"log_out": "",
|
||||||
"log_out_lowercase_dot": "",
|
"log_out_lowercase_dot": "",
|
||||||
"log_viewer_error": "",
|
"log_viewer_error": "",
|
||||||
"logging_in_or_managing_your_account": "",
|
"logging_in_or_managing_your_account": "",
|
||||||
|
@ -782,6 +785,7 @@
|
||||||
"lost_connection": "",
|
"lost_connection": "",
|
||||||
"main_document": "",
|
"main_document": "",
|
||||||
"main_file_not_found": "",
|
"main_file_not_found": "",
|
||||||
|
"main_navigation": "",
|
||||||
"make_a_copy": "",
|
"make_a_copy": "",
|
||||||
"make_email_primary_description": "",
|
"make_email_primary_description": "",
|
||||||
"make_owner": "",
|
"make_owner": "",
|
||||||
|
@ -1358,6 +1362,7 @@
|
||||||
"submit_title": "",
|
"submit_title": "",
|
||||||
"subscribe": "",
|
"subscribe": "",
|
||||||
"subscribe_to_find_the_symbols_you_need_faster": "",
|
"subscribe_to_find_the_symbols_you_need_faster": "",
|
||||||
|
"subscription": "",
|
||||||
"subscription_admins_cannot_be_deleted": "",
|
"subscription_admins_cannot_be_deleted": "",
|
||||||
"subscription_canceled": "",
|
"subscription_canceled": "",
|
||||||
"subscription_canceled_and_terminate_on_x": "",
|
"subscription_canceled_and_terminate_on_x": "",
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
|
||||||
|
const element = document.getElementById('navbar-container')
|
||||||
|
if (element) {
|
||||||
|
const navbarProps = getMeta('ol-navbar')
|
||||||
|
ReactDOM.render(<DefaultNavbar {...navbarProps} />, element)
|
||||||
|
}
|
|
@ -99,8 +99,8 @@ export const DropdownMenu = forwardRef<
|
||||||
})
|
})
|
||||||
DropdownMenu.displayName = 'DropdownMenu'
|
DropdownMenu.displayName = 'DropdownMenu'
|
||||||
|
|
||||||
export function DropdownDivider({ as = 'li' }: DropdownDividerProps) {
|
export function DropdownDivider({ as = 'li', ...props }: DropdownDividerProps) {
|
||||||
return <BS5DropdownDivider as={as} />
|
return <BS5DropdownDivider as={as} {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DropdownHeader({ as = 'li', ...props }: DropdownHeaderProps) {
|
export function DropdownHeader({ as = 'li', ...props }: DropdownHeaderProps) {
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
|
||||||
|
import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
|
||||||
|
import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
|
||||||
|
|
||||||
|
export default function AdminMenu({
|
||||||
|
canDisplayAdminMenu,
|
||||||
|
canDisplayAdminRedirect,
|
||||||
|
canDisplaySplitTestMenu,
|
||||||
|
canDisplaySurveyMenu,
|
||||||
|
adminUrl,
|
||||||
|
}: Pick<
|
||||||
|
DefaultNavbarMetadata,
|
||||||
|
| 'canDisplayAdminMenu'
|
||||||
|
| 'canDisplayAdminRedirect'
|
||||||
|
| 'canDisplaySplitTestMenu'
|
||||||
|
| 'canDisplaySurveyMenu'
|
||||||
|
| 'adminUrl'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<NavDropdownMenu title="Admin" className="subdued">
|
||||||
|
{canDisplayAdminMenu ? (
|
||||||
|
<>
|
||||||
|
<NavDropdownLinkItem href="/admin">Manage Site</NavDropdownLinkItem>
|
||||||
|
<NavDropdownLinkItem href="/admin/user">
|
||||||
|
Manage Users
|
||||||
|
</NavDropdownLinkItem>
|
||||||
|
<NavDropdownLinkItem href="/admin/project">
|
||||||
|
Project URL lookup
|
||||||
|
</NavDropdownLinkItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{canDisplayAdminRedirect && adminUrl ? (
|
||||||
|
<NavDropdownLinkItem href={adminUrl}>
|
||||||
|
Switch to Admin
|
||||||
|
</NavDropdownLinkItem>
|
||||||
|
) : null}
|
||||||
|
{canDisplaySplitTestMenu ? (
|
||||||
|
<NavDropdownLinkItem href="/admin/split-test">
|
||||||
|
Manage Feature Flags
|
||||||
|
</NavDropdownLinkItem>
|
||||||
|
) : null}
|
||||||
|
{canDisplaySurveyMenu ? (
|
||||||
|
<NavDropdownLinkItem href="/admin/survey">
|
||||||
|
Manage Surveys
|
||||||
|
</NavDropdownLinkItem>
|
||||||
|
) : null}
|
||||||
|
</NavDropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
|
||||||
|
import { sendMB } from '@/infrastructure/event-tracking'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
|
||||||
|
import { UserProvider } from '@/shared/context/user-context'
|
||||||
|
|
||||||
|
export default function ContactUsItem() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { modal, showModal } = useContactUsModal({ autofillProjectUrl: false })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NavDropdownLinkItem
|
||||||
|
href="#"
|
||||||
|
onClick={() => {
|
||||||
|
sendMB('menu-clicked-contact')
|
||||||
|
showModal()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('contact_us')}
|
||||||
|
</NavDropdownLinkItem>
|
||||||
|
<UserProvider>{modal}</UserProvider>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { sendMB } from '@/infrastructure/event-tracking'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button, Container, Nav, Navbar } from 'react-bootstrap-5'
|
||||||
|
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
|
||||||
|
import AdminMenu from '@/features/ui/components/bootstrap-5/navbar/admin-menu'
|
||||||
|
import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
|
||||||
|
import NavItemFromData from '@/features/ui/components/bootstrap-5/navbar/nav-item-from-data'
|
||||||
|
import LoggedInItems from '@/features/ui/components/bootstrap-5/navbar/logged-in-items'
|
||||||
|
import HeaderLogoOrTitle from '@/features/ui/components/bootstrap-5/navbar/header-logo-or-title'
|
||||||
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
|
|
||||||
|
function LoggedOutItems() {
|
||||||
|
return <span>Logged out</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefaultNavbar(props: DefaultNavbarMetadata) {
|
||||||
|
const {
|
||||||
|
customLogo,
|
||||||
|
title,
|
||||||
|
canDisplayAdminMenu,
|
||||||
|
canDisplayAdminRedirect,
|
||||||
|
canDisplaySplitTestMenu,
|
||||||
|
canDisplaySurveyMenu,
|
||||||
|
enableUpgradeButton,
|
||||||
|
suppressNavbarRight,
|
||||||
|
suppressNavContentLinks,
|
||||||
|
showSubscriptionLink,
|
||||||
|
sessionUser,
|
||||||
|
adminUrl,
|
||||||
|
items,
|
||||||
|
} = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { isReady } = useWaitForI18n()
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar className="navbar-default navbar-main" expand="lg">
|
||||||
|
<Container className="navbar-container" fluid>
|
||||||
|
<div className="navbar-header">
|
||||||
|
<HeaderLogoOrTitle title={title} customLogo={customLogo} />
|
||||||
|
{enableUpgradeButton ? (
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href="/user/subscription/plans"
|
||||||
|
className="me-2 d-md-none"
|
||||||
|
onClick={() => {
|
||||||
|
sendMB('upgrade-button-click', {
|
||||||
|
source: 'dashboard-top',
|
||||||
|
'project-dashboard-react': 'enabled',
|
||||||
|
'is-dashboard-sidebar-hidden': 'true',
|
||||||
|
'is-screen-width-less-than-768px': 'true',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('upgrade')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{suppressNavbarRight ? null : (
|
||||||
|
<>
|
||||||
|
<Navbar.Toggle
|
||||||
|
aria-controls="navbar-main-collapse"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label={t('main_navigation')}
|
||||||
|
>
|
||||||
|
<MaterialIcon type="menu" />
|
||||||
|
</Navbar.Toggle>
|
||||||
|
<Navbar.Collapse
|
||||||
|
id="navbar-main-collapse"
|
||||||
|
className="justify-content-end"
|
||||||
|
>
|
||||||
|
<Nav as="ul" className="ms-auto" role="menubar">
|
||||||
|
{canDisplayAdminMenu ||
|
||||||
|
canDisplayAdminRedirect ||
|
||||||
|
canDisplaySplitTestMenu ? (
|
||||||
|
<AdminMenu
|
||||||
|
canDisplayAdminMenu={canDisplayAdminMenu}
|
||||||
|
canDisplayAdminRedirect={canDisplayAdminRedirect}
|
||||||
|
canDisplaySplitTestMenu={canDisplaySplitTestMenu}
|
||||||
|
canDisplaySurveyMenu={canDisplaySurveyMenu}
|
||||||
|
adminUrl={adminUrl}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const showNavItem =
|
||||||
|
(item.only_when_logged_in && sessionUser) ||
|
||||||
|
(item.only_when_logged_out && sessionUser) ||
|
||||||
|
(!item.only_when_logged_out &&
|
||||||
|
!item.only_when_logged_in &&
|
||||||
|
!item.only_content_pages) ||
|
||||||
|
(item.only_content_pages && !suppressNavContentLinks)
|
||||||
|
|
||||||
|
return showNavItem ? (
|
||||||
|
<NavItemFromData item={item} key={index} />
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
{sessionUser ? (
|
||||||
|
<LoggedInItems
|
||||||
|
sessionUser={sessionUser}
|
||||||
|
showSubscriptionLink={showSubscriptionLink}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LoggedOutItems />
|
||||||
|
)}
|
||||||
|
</Nav>
|
||||||
|
</Navbar.Collapse>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</Navbar>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DefaultNavbar
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
|
||||||
|
export default function HeaderLogoOrTitle({
|
||||||
|
customLogo,
|
||||||
|
title,
|
||||||
|
}: Pick<DefaultNavbarMetadata, 'customLogo' | 'title'>) {
|
||||||
|
const { appName } = getMeta('ol-ExposedSettings')
|
||||||
|
|
||||||
|
if (customLogo) {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/anchor-has-content
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
aria-label={appName}
|
||||||
|
className="navbar-brand"
|
||||||
|
style={{ backgroundImage: `url("${customLogo}")` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (title) {
|
||||||
|
return (
|
||||||
|
<a href="/" aria-label={appName} className="navbar-title">
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/anchor-has-content
|
||||||
|
<a href="/" aria-label={appName} className="navbar-brand" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Dropdown } from 'react-bootstrap-5'
|
||||||
|
import NavDropdownDivider from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-divider'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
|
||||||
|
import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
|
||||||
|
import NavDropdownItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-item'
|
||||||
|
import type { NavbarSessionUser } from '@/features/ui/components/types/navbar'
|
||||||
|
import NavLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-link-item'
|
||||||
|
|
||||||
|
export default function LoggedInItems({
|
||||||
|
sessionUser,
|
||||||
|
showSubscriptionLink,
|
||||||
|
}: {
|
||||||
|
sessionUser: NavbarSessionUser
|
||||||
|
showSubscriptionLink: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const logOutFormId = 'logOutForm'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NavLinkItem href="/project">{t('projects')}</NavLinkItem>
|
||||||
|
<NavDropdownMenu title={t('Account')}>
|
||||||
|
<Dropdown.Item as="li" disabled role="menuitem">
|
||||||
|
{sessionUser.email}
|
||||||
|
</Dropdown.Item>
|
||||||
|
<NavDropdownDivider />
|
||||||
|
<NavDropdownLinkItem href="/user/settings">
|
||||||
|
{t('Account Settings')}
|
||||||
|
</NavDropdownLinkItem>
|
||||||
|
{showSubscriptionLink ? (
|
||||||
|
<NavDropdownLinkItem href="/user/subscription">
|
||||||
|
{t('subscription')}
|
||||||
|
</NavDropdownLinkItem>
|
||||||
|
) : null}
|
||||||
|
<NavDropdownDivider />
|
||||||
|
<NavDropdownItem>
|
||||||
|
{
|
||||||
|
// The button is outside the form but still belongs to it via the
|
||||||
|
// form attribute. The reason to do this is that if the button is
|
||||||
|
// inside the form, screen readers will not count it in the total
|
||||||
|
// number of menu items
|
||||||
|
}
|
||||||
|
<Dropdown.Item
|
||||||
|
as="button"
|
||||||
|
type="submit"
|
||||||
|
form={logOutFormId}
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
{t('log_out')}
|
||||||
|
</Dropdown.Item>
|
||||||
|
<form id={logOutFormId} method="POST" action="/logout">
|
||||||
|
<input type="hidden" name="_csrf" value={getMeta('ol-csrfToken')} />
|
||||||
|
</form>
|
||||||
|
</NavDropdownItem>
|
||||||
|
</NavDropdownMenu>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { DropdownDivider } from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||||
|
|
||||||
|
export default function NavDropdownDivider() {
|
||||||
|
return <DropdownDivider className="d-none d-lg-block" />
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { NavbarDropdownItemData } from '@/features/ui/components/types/navbar'
|
||||||
|
import NavDropdownDivider from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-divider'
|
||||||
|
import { sendMB } from '@/infrastructure/event-tracking'
|
||||||
|
import { isDropdownLinkItem } from '@/features/ui/components/bootstrap-5/navbar/util'
|
||||||
|
import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
|
||||||
|
import NavDropdownItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-item'
|
||||||
|
import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
|
||||||
|
import ContactUsItem from '@/features/ui/components/bootstrap-5/navbar/contact-us-item'
|
||||||
|
|
||||||
|
export default function NavDropdownFromData({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: NavbarDropdownItemData
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavDropdownMenu title={item.translatedText} className={item.class}>
|
||||||
|
{item.dropdown.map((child, index) => {
|
||||||
|
if ('divider' in child) {
|
||||||
|
return <NavDropdownDivider key={index} />
|
||||||
|
} else if ('isContactUs' in child) {
|
||||||
|
return <ContactUsItem key={index} />
|
||||||
|
} else if (isDropdownLinkItem(child)) {
|
||||||
|
return (
|
||||||
|
<NavDropdownLinkItem
|
||||||
|
key={index}
|
||||||
|
href={child.url}
|
||||||
|
onClick={() => {
|
||||||
|
sendMB(child.event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{child.translatedText}
|
||||||
|
</NavDropdownLinkItem>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<NavDropdownItem key={index}>
|
||||||
|
{child.translatedText}
|
||||||
|
</NavDropdownItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</NavDropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export default function NavDropdownItem({ children }: { children: ReactNode }) {
|
||||||
|
return <li role="none">{children}</li>
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import NavDropdownItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-item'
|
||||||
|
import { DropdownItem } from 'react-bootstrap-5'
|
||||||
|
import { DropdownItemProps } from 'react-bootstrap-5/DropdownItem'
|
||||||
|
|
||||||
|
export default function NavDropdownLinkItem({
|
||||||
|
href,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string
|
||||||
|
onClick?: DropdownItemProps['onClick']
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavDropdownItem>
|
||||||
|
<DropdownItem href={href} role="menuitem" onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</DropdownItem>
|
||||||
|
</NavDropdownItem>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Dropdown } from 'react-bootstrap-5'
|
||||||
|
|
||||||
|
export default function NavDropdownMenu({
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
// Can't use a NavDropdown here because it's impossible to render the menu as
|
||||||
|
// a <ul> element using NavDropdown
|
||||||
|
return (
|
||||||
|
<Dropdown as="li" role="none" className={className}>
|
||||||
|
<Dropdown.Toggle role="menuitem">{title}</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu as="ul" role="menu" align="end">
|
||||||
|
{children}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { NavbarItemData } from '@/features/ui/components/types/navbar'
|
||||||
|
import {
|
||||||
|
isDropdownItem,
|
||||||
|
isLinkItem,
|
||||||
|
} from '@/features/ui/components/bootstrap-5/navbar/util'
|
||||||
|
import NavDropdownFromData from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-from-data'
|
||||||
|
import NavItem from '@/features/ui/components/bootstrap-5/navbar/nav-item'
|
||||||
|
import { sendMB } from '@/infrastructure/event-tracking'
|
||||||
|
import NavLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-link-item'
|
||||||
|
|
||||||
|
export default function NavItemFromData({ item }: { item: NavbarItemData }) {
|
||||||
|
if (isDropdownItem(item)) {
|
||||||
|
return <NavDropdownFromData item={item} />
|
||||||
|
} else if (isLinkItem(item)) {
|
||||||
|
return (
|
||||||
|
<NavLinkItem
|
||||||
|
className={item.class}
|
||||||
|
href={item.url}
|
||||||
|
onClick={() => {
|
||||||
|
sendMB(item.event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.translatedText}
|
||||||
|
</NavLinkItem>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return <NavItem className={item.class}>{item.translatedText}</NavItem>
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Nav, NavItemProps } from 'react-bootstrap-5'
|
||||||
|
|
||||||
|
export default function NavItem(props: Omit<NavItemProps, 'as'>) {
|
||||||
|
const { children, ...rest } = props
|
||||||
|
return (
|
||||||
|
<Nav.Item as="li" role="none" {...rest}>
|
||||||
|
{children}
|
||||||
|
</Nav.Item>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Nav } from 'react-bootstrap-5'
|
||||||
|
import NavItem from '@/features/ui/components/bootstrap-5/navbar/nav-item'
|
||||||
|
|
||||||
|
export default function NavLinkItem({
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string
|
||||||
|
className?: string
|
||||||
|
onClick?: React.ComponentProps<typeof Nav.Link>['onClick']
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavItem className={className}>
|
||||||
|
<Nav.Link role="menuitem" href={href} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</Nav.Link>
|
||||||
|
</NavItem>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type {
|
||||||
|
NavbarDropdownItem,
|
||||||
|
NavbarDropdownItemData,
|
||||||
|
NavbarDropdownLinkItem,
|
||||||
|
NavbarItemData,
|
||||||
|
NavbarLinkItemData,
|
||||||
|
} from '@/features/ui/components/types/navbar'
|
||||||
|
|
||||||
|
export function isDropdownLinkItem(
|
||||||
|
item: NavbarDropdownItem
|
||||||
|
): item is NavbarDropdownLinkItem {
|
||||||
|
return 'url' in item
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDropdownItem(
|
||||||
|
item: NavbarItemData
|
||||||
|
): item is NavbarDropdownItemData {
|
||||||
|
return 'dropdown' in item
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLinkItem(item: NavbarItemData): item is NavbarLinkItemData {
|
||||||
|
return 'url' in item
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type {
|
||||||
|
NavbarItemData,
|
||||||
|
NavbarSessionUser,
|
||||||
|
} from '@/features/ui/components/types/navbar'
|
||||||
|
|
||||||
|
export type DefaultNavbarMetadata = {
|
||||||
|
customLogo?: string
|
||||||
|
title?: string
|
||||||
|
canDisplayAdminMenu: boolean
|
||||||
|
canDisplayAdminRedirect: boolean
|
||||||
|
canDisplaySplitTestMenu: boolean
|
||||||
|
canDisplaySurveyMenu: boolean
|
||||||
|
enableUpgradeButton: boolean
|
||||||
|
suppressNavbarRight: boolean
|
||||||
|
suppressNavContentLinks: boolean
|
||||||
|
showSubscriptionLink: boolean
|
||||||
|
sessionUser?: NavbarSessionUser
|
||||||
|
adminUrl?: string
|
||||||
|
items: NavbarItemData[]
|
||||||
|
}
|
|
@ -55,6 +55,7 @@ export type DropdownMenuProps = PropsWithChildren<{
|
||||||
|
|
||||||
export type DropdownDividerProps = PropsWithChildren<{
|
export type DropdownDividerProps = PropsWithChildren<{
|
||||||
as?: ElementType
|
as?: ElementType
|
||||||
|
className?: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export type DropdownHeaderProps = PropsWithChildren<{
|
export type DropdownHeaderProps = PropsWithChildren<{
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
export interface NavbarDropdownDivider {
|
||||||
|
divider: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavbarDropdownContactUsItem {
|
||||||
|
isContactUs: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavbarDropdownTextItem {
|
||||||
|
text: string
|
||||||
|
translatedText: string
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavbarDropdownLinkItem extends NavbarDropdownTextItem {
|
||||||
|
url: string
|
||||||
|
event: string
|
||||||
|
eventSegmentation?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavbarDropdownItem =
|
||||||
|
| NavbarDropdownDivider
|
||||||
|
| NavbarDropdownContactUsItem
|
||||||
|
| NavbarDropdownTextItem
|
||||||
|
| NavbarDropdownLinkItem
|
||||||
|
|
||||||
|
export type NavbarItemDropdownData = NavbarDropdownItem[]
|
||||||
|
|
||||||
|
export interface NavbarTextItemData {
|
||||||
|
text: string
|
||||||
|
translatedText: string
|
||||||
|
only_when_logged_in?: boolean
|
||||||
|
only_when_logged_out?: boolean
|
||||||
|
only_content_pages?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavbarDropdownItemData extends NavbarTextItemData {
|
||||||
|
dropdown: NavbarItemDropdownData
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavbarLinkItemData extends NavbarTextItemData {
|
||||||
|
url: string
|
||||||
|
event: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavbarItemData =
|
||||||
|
| NavbarDropdownItemData
|
||||||
|
| NavbarLinkItemData
|
||||||
|
| NavbarTextItemData
|
||||||
|
|
||||||
|
export type NavbarSessionUser = { email: string }
|
|
@ -1,7 +1,7 @@
|
||||||
import getMeta from '@/utils/meta'
|
import getMeta from '@/utils/meta'
|
||||||
|
|
||||||
// The reason this is a function to ensure that meta tag is read before any
|
// The reason this is a function is to ensure that the meta tag is read before
|
||||||
// isBootstrap5 check is performed
|
// any isBootstrap5 check is performed
|
||||||
export const isBootstrap5 = () => getMeta('ol-bootstrapVersion') === 5
|
export const isBootstrap5 = () => getMeta('ol-bootstrapVersion') === 5
|
||||||
|
|
||||||
export const bsVersion = ({ bs5, bs3 }: { bs5?: string; bs3?: string }) => {
|
export const bsVersion = ({ bs5, bs3 }: { bs5?: string; bs3?: string }) => {
|
||||||
|
|
|
@ -9,3 +9,4 @@ import './features/multi-submit'
|
||||||
import './features/cookie-banner'
|
import './features/cookie-banner'
|
||||||
import './features/autoplay-video'
|
import './features/autoplay-video'
|
||||||
import './features/mathjax'
|
import './features/mathjax'
|
||||||
|
import './features/header-footer-react'
|
||||||
|
|
|
@ -2,10 +2,10 @@ import './../utils/meta'
|
||||||
import './../utils/webpack-public-path'
|
import './../utils/webpack-public-path'
|
||||||
import './../infrastructure/error-reporter'
|
import './../infrastructure/error-reporter'
|
||||||
import './../i18n'
|
import './../i18n'
|
||||||
import '../features/contact-form'
|
|
||||||
import '../features/event-tracking'
|
import '../features/event-tracking'
|
||||||
import '../features/cookie-banner'
|
import '../features/cookie-banner'
|
||||||
import '../features/link-helpers/slow-link'
|
import '../features/link-helpers/slow-link'
|
||||||
|
import '../features/header-footer-react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import ProjectListRoot from '../features/project-list/components/project-list-root'
|
import ProjectListRoot from '../features/project-list/components/project-list-root'
|
||||||
|
|
||||||
|
|
|
@ -46,6 +46,7 @@ import { PasswordStrengthOptions } from '../../../types/password-strength-option
|
||||||
import { Subscription as ProjectDashboardSubscription } from '../../../types/project/dashboard/subscription'
|
import { Subscription as ProjectDashboardSubscription } from '../../../types/project/dashboard/subscription'
|
||||||
import { ThirdPartyIds } from '../../../types/third-party-ids'
|
import { ThirdPartyIds } from '../../../types/third-party-ids'
|
||||||
import { Publisher } from '../../../types/subscription/dashboard/publisher'
|
import { Publisher } from '../../../types/subscription/dashboard/publisher'
|
||||||
|
import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
|
||||||
|
|
||||||
export interface Meta {
|
export interface Meta {
|
||||||
'ol-ExposedSettings': ExposedSettings
|
'ol-ExposedSettings': ExposedSettings
|
||||||
|
@ -135,6 +136,7 @@ export interface Meta {
|
||||||
'ol-memberGroupSubscriptions': MemberGroupSubscription[]
|
'ol-memberGroupSubscriptions': MemberGroupSubscription[]
|
||||||
'ol-memberOfSSOEnabledGroups': GroupSSOLinkingStatus[]
|
'ol-memberOfSSOEnabledGroups': GroupSSOLinkingStatus[]
|
||||||
'ol-members': MinimalUser[]
|
'ol-members': MinimalUser[]
|
||||||
|
'ol-navbar': DefaultNavbarMetadata
|
||||||
'ol-no-single-dollar': boolean
|
'ol-no-single-dollar': boolean
|
||||||
'ol-notifications': NotificationType[]
|
'ol-notifications': NotificationType[]
|
||||||
'ol-notificationsInstitution': InstitutionType[]
|
'ol-notificationsInstitution': InstitutionType[]
|
||||||
|
|
|
@ -204,6 +204,9 @@ $navbar-toggler-padding-x: var(--spacing-05);
|
||||||
$navbar-toggler-padding-y: var(--spacing-02);
|
$navbar-toggler-padding-y: var(--spacing-02);
|
||||||
$navbar-toggler-border-radius: var(--border-radius-base);
|
$navbar-toggler-border-radius: var(--border-radius-base);
|
||||||
|
|
||||||
|
// Nav links
|
||||||
|
$navbar-nav-link-padding-x: var(--navbar-btn-padding-h);
|
||||||
|
|
||||||
// Spacing scale used by Bootstrap utilities
|
// Spacing scale used by Bootstrap utilities
|
||||||
$spacer: $spacing-06;
|
$spacer: $spacing-06;
|
||||||
$spacers: (
|
$spacers: (
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
> li {
|
> li {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
||||||
> a,
|
> .nav-link,
|
||||||
> .dropdown-toggle {
|
> .dropdown-toggle {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--navbar-link-color);
|
color: var(--navbar-link-color);
|
||||||
|
@ -69,7 +69,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.subdued > a,
|
&.subdued > .nav-link,
|
||||||
&.subdued > .dropdown-toggle {
|
&.subdued > .dropdown-toggle {
|
||||||
border: 0;
|
border: 0;
|
||||||
color: var(--navbar-subdued-color);
|
color: var(--navbar-subdued-color);
|
||||||
|
@ -87,6 +87,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
|
--bs-navbar-toggler-padding-x: var(--spacing-04);
|
||||||
|
|
||||||
color: var(--navbar-link-color);
|
color: var(--navbar-link-color);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
border-width: 0;
|
border-width: 0;
|
||||||
|
@ -97,6 +99,12 @@
|
||||||
background-color: var(--navbar-toggler-expanded-bg);
|
background-color: var(--navbar-toggler-expanded-bg);
|
||||||
transition: 0.2s ease-in;
|
transition: 0.2s ease-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .material-symbols {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-collapse,
|
.navbar-collapse,
|
||||||
|
@ -129,7 +137,8 @@
|
||||||
> a,
|
> a,
|
||||||
> .dropdown-toggle {
|
> .dropdown-toggle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--spacing-05) var(--navbar-padding-h);
|
padding-top: var(--spacing-05);
|
||||||
|
padding-bottom: var(--spacing-05);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|
|
@ -674,11 +674,11 @@
|
||||||
"footer_about_us": "About us",
|
"footer_about_us": "About us",
|
||||||
"footer_contact_us": "Contact us",
|
"footer_contact_us": "Contact us",
|
||||||
"footer_navigation": "Footer navigation",
|
"footer_navigation": "Footer navigation",
|
||||||
"footer_plans_and_pricing": "Plans & pricing",
|
"footer_plans_and_pricing": "Plans & pricing",
|
||||||
"for_business": "For business",
|
"for_business": "For business",
|
||||||
"for_enterprise": "For enterprise",
|
"for_enterprise": "For enterprise",
|
||||||
"for_groups_or_site_wide": "For groups or site-wide",
|
"for_groups_or_site_wide": "For groups or site-wide",
|
||||||
"for_individuals_and_groups": "For individuals & groups",
|
"for_individuals_and_groups": "For individuals & groups",
|
||||||
"for_large_institutions_and_organizations_need_sitewide_on_premise": "For large institutions and organizations that need site-wide access or an on-premises solution.",
|
"for_large_institutions_and_organizations_need_sitewide_on_premise": "For large institutions and organizations that need site-wide access or an on-premises solution.",
|
||||||
"for_more_information_see_managed_accounts_section": "For more information, see the \"Managed Accounts\" section in <0>our terms of use</0>, which you agree to by clicking Accept invitation.",
|
"for_more_information_see_managed_accounts_section": "For more information, see the \"Managed Accounts\" section in <0>our terms of use</0>, which you agree to by clicking Accept invitation.",
|
||||||
"for_publishers": "For publishers",
|
"for_publishers": "For publishers",
|
||||||
|
@ -1151,6 +1151,7 @@
|
||||||
"lost_connection": "Lost Connection",
|
"lost_connection": "Lost Connection",
|
||||||
"main_document": "Main document",
|
"main_document": "Main document",
|
||||||
"main_file_not_found": "Unknown main document",
|
"main_file_not_found": "Unknown main document",
|
||||||
|
"main_navigation": "Main navigation",
|
||||||
"maintenance": "Maintenance",
|
"maintenance": "Maintenance",
|
||||||
"make_a_copy": "Make a copy",
|
"make_a_copy": "Make a copy",
|
||||||
"make_email_primary_description": "Make this the primary email, used to log in",
|
"make_email_primary_description": "Make this the primary email, used to log in",
|
||||||
|
@ -1443,7 +1444,7 @@
|
||||||
"plan": "Plan",
|
"plan": "Plan",
|
||||||
"plan_tooltip": "You’re on the __plan__ plan. Click to find out how to make the most of your Overleaf premium features.",
|
"plan_tooltip": "You’re on the __plan__ plan. Click to find out how to make the most of your Overleaf premium features.",
|
||||||
"planned_maintenance": "Planned Maintenance",
|
"planned_maintenance": "Planned Maintenance",
|
||||||
"plans_amper_pricing": "Plans & Pricing",
|
"plans_amper_pricing": "Plans & Pricing",
|
||||||
"plans_and_pricing": "Plans and Pricing",
|
"plans_and_pricing": "Plans and Pricing",
|
||||||
"plans_and_pricing_lowercase": "plans and pricing",
|
"plans_and_pricing_lowercase": "plans and pricing",
|
||||||
"please_ask_the_project_owner_to_upgrade_to_track_changes": "Please ask the project owner to upgrade to use track changes",
|
"please_ask_the_project_owner_to_upgrade_to_track_changes": "Please ask the project owner to upgrade to use track changes",
|
||||||
|
@ -1486,7 +1487,7 @@
|
||||||
"premium_plan_label": "You’re using <b>Overleaf Premium</b>",
|
"premium_plan_label": "You’re using <b>Overleaf Premium</b>",
|
||||||
"presentation": "Presentation",
|
"presentation": "Presentation",
|
||||||
"presentation_mode": "Presentation mode",
|
"presentation_mode": "Presentation mode",
|
||||||
"press_and_awards": "Press & awards",
|
"press_and_awards": "Press & awards",
|
||||||
"previous_page": "Previous page",
|
"previous_page": "Previous page",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
"primarily_work_study_question": "Where do you primarily work or study?",
|
"primarily_work_study_question": "Where do you primarily work or study?",
|
||||||
|
|
Loading…
Reference in a new issue