fix(frontend): improve performance by caching translated texts

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-06-26 22:32:19 +02:00
parent ced4cd953c
commit 76242330fd
81 changed files with 341 additions and 292 deletions

View file

@ -9,8 +9,7 @@ import { createNumberRangeArray } from '../../common/number-range/number-range'
import styles from './animations.module.scss' import styles from './animations.module.scss'
import { IconRow } from './icon-row' import { IconRow } from './icon-row'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Pencil as IconPencil } from 'react-bootstrap-icons' import { Pencil as IconPencil, PencilFill as IconPencilFill } from 'react-bootstrap-icons'
import { PencilFill as IconPencilFill } from 'react-bootstrap-icons'
export interface HedgeDocLogoProps { export interface HedgeDocLogoProps {
error: boolean error: boolean

View file

@ -6,18 +6,20 @@
import styles from './animations.module.scss' import styles from './animations.module.scss'
import React, { Fragment, useEffect, useState } from 'react' import React, { Fragment, useEffect, useState } from 'react'
import type { Icon } from 'react-bootstrap-icons' import type { Icon } from 'react-bootstrap-icons'
import { FileText as IconFileText } from 'react-bootstrap-icons' import {
import { File as IconFile } from 'react-bootstrap-icons' File as IconFile,
import { Fonts as IconFonts } from 'react-bootstrap-icons' FileText as IconFileText,
import { Gear as IconGear } from 'react-bootstrap-icons' Fonts as IconFonts,
import { KeyboardFill as IconKeyboardFill } from 'react-bootstrap-icons' Gear as IconGear,
import { ListCheck as IconListCheck } from 'react-bootstrap-icons' KeyboardFill as IconKeyboardFill,
import { Markdown as IconMarkdown } from 'react-bootstrap-icons' ListCheck as IconListCheck,
import { Pencil as IconPencil } from 'react-bootstrap-icons' Markdown as IconMarkdown,
import { Person as IconPerson } from 'react-bootstrap-icons' Pencil as IconPencil,
import { Tag as IconTag } from 'react-bootstrap-icons' Person as IconPerson,
import { TypeBold as IconTypeBold } from 'react-bootstrap-icons' Tag as IconTag,
import { TypeItalic as IconTypeItalic } from 'react-bootstrap-icons' TypeBold as IconTypeBold,
TypeItalic as IconTypeItalic
} from 'react-bootstrap-icons'
const elements: Icon[] = [ const elements: Icon[] = [
IconFileText, IconFileText,

View file

@ -3,6 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../../hooks/common/use-translated-text'
import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute' import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import { UiIcon } from '../../icons/ui-icon' import { UiIcon } from '../../icons/ui-icon'
@ -11,7 +12,6 @@ import React, { Fragment, useRef } from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { Files as IconFiles } from 'react-bootstrap-icons' import { Files as IconFiles } from 'react-bootstrap-icons'
import type { Variant } from 'react-bootstrap/types' import type { Variant } from 'react-bootstrap/types'
import { useTranslation } from 'react-i18next'
export interface CopyToClipboardButtonProps extends PropsWithDataCypressId { export interface CopyToClipboardButtonProps extends PropsWithDataCypressId {
content: string content: string
@ -33,10 +33,9 @@ export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
variant = 'dark', variant = 'dark',
...props ...props
}) => { }) => {
const { t } = useTranslation()
const button = useRef<HTMLButtonElement>(null) const button = useRef<HTMLButtonElement>(null)
const [copyToClipboard, overlayElement] = useCopyOverlay(button, content) const [copyToClipboard, overlayElement] = useCopyOverlay(button, content)
const buttonTitle = useTranslatedText('renderer.highlightCode.copyCode')
return ( return (
<Fragment> <Fragment>
@ -44,7 +43,7 @@ export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
ref={button} ref={button}
size={size} size={size}
variant={variant} variant={variant}
title={t('renderer.highlightCode.copyCode') ?? undefined} title={buttonTitle}
onClick={copyToClipboard} onClick={copyToClipboard}
{...cypressId(props)}> {...cypressId(props)}>
<UiIcon icon={IconFiles} /> <UiIcon icon={IconFiles} />

View file

@ -3,10 +3,11 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import type { CommonFieldProps } from './fields' import type { CommonFieldProps } from './fields'
import React from 'react' import React from 'react'
import { Form } from 'react-bootstrap' import { Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
/** /**
* Renders an input field for the current password when changing passwords. * Renders an input field for the current password when changing passwords.
@ -14,7 +15,7 @@ import { Trans, useTranslation } from 'react-i18next'
* @param onChange Hook that is called when the entered password changes. * @param onChange Hook that is called when the entered password changes.
*/ */
export const CurrentPasswordField: React.FC<CommonFieldProps> = ({ onChange }) => { export const CurrentPasswordField: React.FC<CommonFieldProps> = ({ onChange }) => {
const { t } = useTranslation() const placeholderText = useTranslatedText('login.auth.password')
return ( return (
<Form.Group> <Form.Group>
@ -25,7 +26,7 @@ export const CurrentPasswordField: React.FC<CommonFieldProps> = ({ onChange }) =
type='password' type='password'
size='sm' size='sm'
onChange={onChange} onChange={onChange}
placeholder={t('login.auth.password') ?? undefined} placeholder={placeholderText}
autoComplete='current-password' autoComplete='current-password'
required required
/> />

View file

@ -3,10 +3,11 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import type { CommonFieldProps } from './fields' import type { CommonFieldProps } from './fields'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Form } from 'react-bootstrap' import { Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
interface DisplayNameFieldProps extends CommonFieldProps { interface DisplayNameFieldProps extends CommonFieldProps {
initialValue?: string initialValue?: string
@ -20,11 +21,8 @@ interface DisplayNameFieldProps extends CommonFieldProps {
* @param initialValue The initial input field value. * @param initialValue The initial input field value.
*/ */
export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({ onChange, value, initialValue }) => { export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({ onChange, value, initialValue }) => {
const { t } = useTranslation() const isValid = useMemo(() => value.trim() !== '' && value !== initialValue, [value, initialValue])
const placeholderText = useTranslatedText('profile.displayName')
const isValid = useMemo(() => {
return value.trim() !== '' && value !== initialValue
}, [value, initialValue])
return ( return (
<Form.Group> <Form.Group>
@ -37,7 +35,7 @@ export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({ onChange, va
value={value} value={value}
isValid={isValid} isValid={isValid}
onChange={onChange} onChange={onChange}
placeholder={t('profile.displayName') ?? undefined} placeholder={placeholderText}
autoComplete='name' autoComplete='name'
required required
/> />

View file

@ -3,10 +3,11 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import type { CommonFieldProps } from './fields' import type { CommonFieldProps } from './fields'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Form } from 'react-bootstrap' import { Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
/** /**
* Renders an input field for the new password when registering. * Renders an input field for the new password when registering.
@ -15,11 +16,9 @@ import { Trans, useTranslation } from 'react-i18next'
* @param value The currently entered password. * @param value The currently entered password.
*/ */
export const NewPasswordField: React.FC<CommonFieldProps> = ({ onChange, value, hasError = false }) => { export const NewPasswordField: React.FC<CommonFieldProps> = ({ onChange, value, hasError = false }) => {
const { t } = useTranslation() const isValid = useMemo(() => value.trim() !== '', [value])
const isValid = useMemo(() => { const placeholderText = useTranslatedText('login.auth.password')
return value.trim() !== ''
}, [value])
return ( return (
<Form.Group> <Form.Group>
@ -32,7 +31,7 @@ export const NewPasswordField: React.FC<CommonFieldProps> = ({ onChange, value,
isValid={isValid} isValid={isValid}
isInvalid={hasError} isInvalid={hasError}
onChange={onChange} onChange={onChange}
placeholder={t('login.auth.password') ?? undefined} placeholder={placeholderText}
autoComplete='new-password' autoComplete='new-password'
required required
/> />

View file

@ -3,10 +3,11 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import type { CommonFieldProps } from './fields' import type { CommonFieldProps } from './fields'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Form } from 'react-bootstrap' import { Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
interface PasswordAgainFieldProps extends CommonFieldProps { interface PasswordAgainFieldProps extends CommonFieldProps {
password: string password: string
@ -15,9 +16,10 @@ interface PasswordAgainFieldProps extends CommonFieldProps {
/** /**
* Renders an input field for typing the new password again when registering. * Renders an input field for typing the new password again when registering.
* *
* @param onChange Hook that is called when the entered retype of the password changes. * @param onChange Hook that is called when the entered retype of the password changes
* @param value The currently entered retype of the password. * @param value The currently entered retype of the password
* @param password The password entered into the password input field. * @param password The password entered into the password input field
* @param hasError Defines if the password should be shown as invalid
*/ */
export const PasswordAgainField: React.FC<PasswordAgainFieldProps> = ({ export const PasswordAgainField: React.FC<PasswordAgainFieldProps> = ({
onChange, onChange,
@ -25,15 +27,9 @@ export const PasswordAgainField: React.FC<PasswordAgainFieldProps> = ({
password, password,
hasError = false hasError = false
}) => { }) => {
const { t } = useTranslation() const isInvalid = useMemo(() => value !== '' && password !== value && hasError, [password, value, hasError])
const isValid = useMemo(() => password !== '' && password === value && !hasError, [password, value, hasError])
const isInvalid = useMemo(() => { const placeholderText = useTranslatedText('login.register.passwordAgain')
return value !== '' && password !== value && hasError
}, [password, value, hasError])
const isValid = useMemo(() => {
return password !== '' && password === value && !hasError
}, [password, value, hasError])
return ( return (
<Form.Group> <Form.Group>
@ -46,7 +42,7 @@ export const PasswordAgainField: React.FC<PasswordAgainFieldProps> = ({
isInvalid={isInvalid} isInvalid={isInvalid}
isValid={isValid} isValid={isValid}
onChange={onChange} onChange={onChange}
placeholder={t('login.register.passwordAgain') ?? undefined} placeholder={placeholderText}
autoComplete='new-password' autoComplete='new-password'
required required
/> />

View file

@ -3,10 +3,10 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import type { CommonFieldProps } from './fields' import type { CommonFieldProps } from './fields'
import React from 'react' import React from 'react'
import { Form } from 'react-bootstrap' import { Form } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
export interface UsernameFieldProps extends CommonFieldProps { export interface UsernameFieldProps extends CommonFieldProps {
isInvalid?: boolean isInvalid?: boolean
@ -22,7 +22,7 @@ export interface UsernameFieldProps extends CommonFieldProps {
* @param isInvalid Adds error style to label * @param isInvalid Adds error style to label
*/ */
export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, isValid, isInvalid }) => { export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, isValid, isInvalid }) => {
const { t } = useTranslation() const placeholderText = useTranslatedText('login.auth.username')
return ( return (
<Form.Control <Form.Control
@ -32,7 +32,7 @@ export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, i
isValid={isValid} isValid={isValid}
isInvalid={isInvalid} isInvalid={isInvalid}
onChange={onChange} onChange={onChange}
placeholder={t('login.auth.username') ?? undefined} placeholder={placeholderText}
autoComplete='username' autoComplete='username'
autoFocus={true} autoFocus={true}
required required

View file

@ -3,8 +3,8 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { UsernameField } from './username-field'
import type { UsernameFieldProps } from './username-field' import type { UsernameFieldProps } from './username-field'
import { UsernameField } from './username-field'
import React from 'react' import React from 'react'
import { Form } from 'react-bootstrap' import { Form } from 'react-bootstrap'
import { Trans } from 'react-i18next' import { Trans } from 'react-i18next'

View file

@ -4,8 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { testId } from '../../../utils/test-id' import { testId } from '../../../utils/test-id'
import { BootstrapLazyIcons } from './bootstrap-icons'
import type { BootstrapIconName } from './bootstrap-icons' import type { BootstrapIconName } from './bootstrap-icons'
import { BootstrapLazyIcons } from './bootstrap-icons'
import React, { Suspense, useMemo } from 'react' import React, { Suspense, useMemo } from 'react'
export interface LazyBootstrapIconProps { export interface LazyBootstrapIconProps {

View file

@ -3,10 +3,10 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { ExternalLink } from './external-link' import { ExternalLink } from './external-link'
import type { TranslatedLinkProps } from './types' import type { TranslatedLinkProps } from './types'
import React, { useMemo } from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
/** /**
* An {@link ExternalLink external link} with translated text. * An {@link ExternalLink external link} with translated text.
@ -16,7 +16,6 @@ import { useTranslation } from 'react-i18next'
* @param props Additional props directly given to the {@link ExternalLink} * @param props Additional props directly given to the {@link ExternalLink}
*/ */
export const TranslatedExternalLink: React.FC<TranslatedLinkProps> = ({ i18nKey, i18nOption, ...props }) => { export const TranslatedExternalLink: React.FC<TranslatedLinkProps> = ({ i18nKey, i18nOption, ...props }) => {
const { t } = useTranslation() const text = useTranslatedText(i18nKey, i18nOption)
const text = useMemo(() => (i18nOption ? t(i18nKey, i18nOption) : t(i18nKey)), [i18nKey, i18nOption, t])
return <ExternalLink text={text} {...props} /> return <ExternalLink text={text} {...props} />
} }

View file

@ -3,10 +3,10 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { InternalLink } from './internal-link' import { InternalLink } from './internal-link'
import type { TranslatedLinkProps } from './types' import type { TranslatedLinkProps } from './types'
import React, { useMemo } from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
/** /**
* An {@link InternalLink internal link} with translated text. * An {@link InternalLink internal link} with translated text.
@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'
* @param props Additional props directly given to the {@link InternalLink} * @param props Additional props directly given to the {@link InternalLink}
*/ */
export const TranslatedInternalLink: React.FC<TranslatedLinkProps> = ({ i18nKey, i18nOption, ...props }) => { export const TranslatedInternalLink: React.FC<TranslatedLinkProps> = ({ i18nKey, i18nOption, ...props }) => {
const { t } = useTranslation() const text = useTranslatedText(i18nKey, i18nOption)
const text = useMemo(() => (i18nOption ? t(i18nKey, i18nOption) : t(i18nKey)), [i18nKey, i18nOption, t])
return <InternalLink text={text} {...props} /> return <InternalLink text={text} {...props} />
} }

View file

@ -11,7 +11,7 @@ import { RendererType } from '../../render-page/window-post-message-communicator
import { CommonModal } from '../modals/common-modal' import { CommonModal } from '../modals/common-modal'
import { RendererIframe } from '../renderer-iframe/renderer-iframe' import { RendererIframe } from '../renderer-iframe/renderer-iframe'
import { fetchMotd, MOTD_LOCAL_STORAGE_KEY } from './fetch-motd' import { fetchMotd, MOTD_LOCAL_STORAGE_KEY } from './fetch-motd'
import React, { useCallback, useMemo, useEffect, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Modal } from 'react-bootstrap' import { Button, Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { useAsync } from 'react-use' import { useAsync } from 'react-use'

View file

@ -10,9 +10,11 @@ import { UiIcon } from '../icons/ui-icon'
import { ShowIf } from '../show-if/show-if' import { ShowIf } from '../show-if/show-if'
import React, { useCallback, useEffect } from 'react' import React, { useCallback, useEffect } from 'react'
import { Alert, Button } from 'react-bootstrap' import { Alert, Button } from 'react-bootstrap'
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons' import {
import { CheckCircle as IconCheckCircle } from 'react-bootstrap-icons' ArrowRepeat as IconArrowRepeat,
import { ExclamationTriangle as IconExclamationTriangle } from 'react-bootstrap-icons' CheckCircle as IconCheckCircle,
ExclamationTriangle as IconExclamationTriangle
} from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { useAsyncFn } from 'react-use' import { useAsyncFn } from 'react-use'

View file

@ -15,9 +15,9 @@ import { useEditorReceiveHandler } from '../../render-page/window-post-message-c
import type { import type {
ExtensionEvent, ExtensionEvent,
OnHeightChangeMessage, OnHeightChangeMessage,
RendererType,
SetScrollStateMessage SetScrollStateMessage
} from '../../render-page/window-post-message-communicator/rendering-message' } from '../../render-page/window-post-message-communicator/rendering-message'
import type { RendererType } from '../../render-page/window-post-message-communicator/rendering-message'
import { CommunicationMessageType } from '../../render-page/window-post-message-communicator/rendering-message' import { CommunicationMessageType } from '../../render-page/window-post-message-communicator/rendering-message'
import { ShowIf } from '../show-if/show-if' import { ShowIf } from '../show-if/show-if'
import { WaitSpinner } from '../wait-spinner/wait-spinner' import { WaitSpinner } from '../wait-spinner/wait-spinner'

View file

@ -3,13 +3,13 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { ShowIf } from '../show-if/show-if' import { ShowIf } from '../show-if/show-if'
import defaultAvatar from './default-avatar.png' import defaultAvatar from './default-avatar.png'
import styles from './user-avatar.module.scss' import styles from './user-avatar.module.scss'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import type { OverlayInjectedProps } from 'react-bootstrap/Overlay' import type { OverlayInjectedProps } from 'react-bootstrap/Overlay'
import { useTranslation } from 'react-i18next'
export interface UserAvatarProps { export interface UserAvatarProps {
size?: 'sm' | 'lg' size?: 'sm' | 'lg'
@ -34,8 +34,6 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
additionalClasses = '', additionalClasses = '',
showName = true showName = true
}) => { }) => {
const { t } = useTranslation()
const imageSize = useMemo(() => { const imageSize = useMemo(() => {
switch (size) { switch (size) {
case 'sm': case 'sm':
@ -51,7 +49,13 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
return photoUrl || defaultAvatar.src return photoUrl || defaultAvatar.src
}, [photoUrl]) }, [photoUrl])
const imgDescription = useMemo(() => t('common.avatarOf', { name: displayName }), [t, displayName]) const imageTranslateOptions = useMemo(
() => ({
name: displayName
}),
[displayName]
)
const imgDescription = useTranslatedText('common.avatarOf', imageTranslateOptions)
const tooltip = useCallback( const tooltip = useCallback(
(overlayInjectedProps: OverlayInjectedProps) => ( (overlayInjectedProps: OverlayInjectedProps) => (

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from '../../hooks/common/use-application-state' import { useApplicationState } from '../../hooks/common/use-application-state'
import { useTranslatedText } from '../../hooks/common/use-translated-text'
import { InternalLink } from '../common/links/internal-link' import { InternalLink } from '../common/links/internal-link'
import { ShowIf } from '../common/show-if/show-if' import { ShowIf } from '../common/show-if/show-if'
import { NoteInfoLineCreatedAt } from '../editor-page/sidebar/specific-sidebar-entries/note-info-sidebar-menu/note-info-line/note-info-line-created-at' import { NoteInfoLineCreatedAt } from '../editor-page/sidebar/specific-sidebar-entries/note-info-sidebar-menu/note-info-line/note-info-line-created-at'
@ -11,16 +12,16 @@ import { NoteInfoLineUpdatedBy } from '../editor-page/sidebar/specific-sidebar-e
import styles from './document-infobar.module.scss' import styles from './document-infobar.module.scss'
import React from 'react' import React from 'react'
import { Pencil as IconPencil } from 'react-bootstrap-icons' import { Pencil as IconPencil } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
/** /**
* Renders an info bar with metadata about the current note. * Renders an info bar with metadata about the current note.
*/ */
export const DocumentInfobar: React.FC = () => { export const DocumentInfobar: React.FC = () => {
const { t } = useTranslation()
const noteDetails = useApplicationState((state) => state.noteDetails) const noteDetails = useApplicationState((state) => state.noteDetails)
// TODO Check permissions ("writability") of note and show edit link depending on that. // TODO Check permissions ("writability") of note and show edit link depending on that.
const linkTitle = useTranslatedText('views.readOnly.editNote')
return ( return (
<div className={`d-flex flex-row my-3 ${styles['document-infobar']}`}> <div className={`d-flex flex-row my-3 ${styles['document-infobar']}`}>
<div className={'col-md'}>&nbsp;</div> <div className={'col-md'}>&nbsp;</div>
@ -38,7 +39,7 @@ export const DocumentInfobar: React.FC = () => {
href={`/n/${noteDetails.primaryAddress}`} href={`/n/${noteDetails.primaryAddress}`}
icon={IconPencil} icon={IconPencil}
className={'text-primary text-decoration-none mx-1'} className={'text-primary text-decoration-none mx-1'}
title={t('views.readOnly.editNote') ?? undefined} title={linkTitle}
/> />
</ShowIf> </ShowIf>
</span> </span>

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useBooleanState } from '../../../../hooks/common/use-boolean-state' import { useBooleanState } from '../../../../hooks/common/use-boolean-state'
import { useTranslatedText } from '../../../../hooks/common/use-translated-text'
import { useOutlineButtonVariant } from '../../../../hooks/dark-mode/use-outline-button-variant' import { useOutlineButtonVariant } from '../../../../hooks/dark-mode/use-outline-button-variant'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import { CommonModal } from '../../../common/modals/common-modal' import { CommonModal } from '../../../common/modals/common-modal'
@ -11,21 +12,21 @@ import { CheatsheetContent } from './cheatsheet-content'
import { CheatsheetInNewTabButton } from './cheatsheet-in-new-tab-button' import { CheatsheetInNewTabButton } from './cheatsheet-in-new-tab-button'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { Button, Modal } from 'react-bootstrap' import { Button, Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
/** /**
* Shows a button that opens the cheatsheet dialog. * Shows a button that opens the cheatsheet dialog.
*/ */
export const CheatsheetButton: React.FC = () => { export const CheatsheetButton: React.FC = () => {
const { t } = useTranslation()
const [modalVisibility, showModal, closeModal] = useBooleanState() const [modalVisibility, showModal, closeModal] = useBooleanState()
const buttonVariant = useOutlineButtonVariant() const buttonVariant = useOutlineButtonVariant()
const buttonTitle = useTranslatedText('cheatsheet.button')
return ( return (
<Fragment> <Fragment>
<Button <Button
{...cypressId('open.cheatsheet-button')} {...cypressId('open.cheatsheet-button')}
title={t('cheatsheet.button') ?? undefined} title={buttonTitle}
className={'mx-2'} className={'mx-2'}
variant={buttonVariant} variant={buttonVariant}
size={'sm'} size={'sm'}

View file

@ -3,12 +3,12 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../../hooks/common/use-translated-text'
import { useOutlineButtonVariant } from '../../../../hooks/dark-mode/use-outline-button-variant' import { useOutlineButtonVariant } from '../../../../hooks/dark-mode/use-outline-button-variant'
import { IconButton } from '../../../common/icon-button/icon-button' import { IconButton } from '../../../common/icon-button/icon-button'
import type { MouseEvent } from 'react' import type { MouseEvent } from 'react'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { BoxArrowUpRight } from 'react-bootstrap-icons' import { BoxArrowUpRight } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
export interface CheatsheetInNewTabButtonProps { export interface CheatsheetInNewTabButtonProps {
onClick?: () => void onClick?: () => void
@ -26,10 +26,8 @@ export const CheatsheetInNewTabButton: React.FC<CheatsheetInNewTabButtonProps> =
}, },
[onClick] [onClick]
) )
const { t } = useTranslation()
const buttonVariant = useOutlineButtonVariant() const buttonVariant = useOutlineButtonVariant()
const buttonTitle = useTranslatedText('cheatsheet.modal.popup')
return ( return (
<IconButton <IconButton
@ -41,7 +39,7 @@ export const CheatsheetInNewTabButton: React.FC<CheatsheetInNewTabButtonProps> =
className={'p-2 border-0'} className={'p-2 border-0'}
variant={buttonVariant} variant={buttonVariant}
target={'_blank'} target={'_blank'}
title={t('cheatsheet.modal.popup') ?? ''} title={buttonTitle}
/> />
) )
} }

View file

@ -4,28 +4,29 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useBooleanState } from '../../../../hooks/common/use-boolean-state' import { useBooleanState } from '../../../../hooks/common/use-boolean-state'
import { useTranslatedText } from '../../../../hooks/common/use-translated-text'
import { useOutlineButtonVariant } from '../../../../hooks/dark-mode/use-outline-button-variant' import { useOutlineButtonVariant } from '../../../../hooks/dark-mode/use-outline-button-variant'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import { IconButton } from '../../../common/icon-button/icon-button' import { IconButton } from '../../../common/icon-button/icon-button'
import { HelpModal } from './help-modal' import { HelpModal } from './help-modal'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { QuestionCircle as IconQuestionCircle } from 'react-bootstrap-icons' import { QuestionCircle as IconQuestionCircle } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
/** /**
* Renders the button to open the {@link HelpModal}. * Renders the button to open the {@link HelpModal}.
*/ */
export const HelpButton: React.FC = () => { export const HelpButton: React.FC = () => {
const { t } = useTranslation()
const [modalVisibility, showModal, closeModal] = useBooleanState() const [modalVisibility, showModal, closeModal] = useBooleanState()
const buttonVariant = useOutlineButtonVariant() const buttonVariant = useOutlineButtonVariant()
const buttonTitle = useTranslatedText('editor.documentBar.help')
return ( return (
<Fragment> <Fragment>
<IconButton <IconButton
icon={IconQuestionCircle} icon={IconQuestionCircle}
{...cypressId('editor-help-button')} {...cypressId('editor-help-button')}
title={t('editor.documentBar.help') ?? undefined} title={buttonTitle}
className='ms-2' className='ms-2'
size='sm' size='sm'
variant={buttonVariant} variant={buttonVariant}

View file

@ -9,10 +9,7 @@ import { TranslatedExternalLink } from '../../../common/links/translated-externa
import { TranslatedInternalLink } from '../../../common/links/translated-internal-link' import { TranslatedInternalLink } from '../../../common/links/translated-internal-link'
import React from 'react' import React from 'react'
import { Col, Row } from 'react-bootstrap' import { Col, Row } from 'react-bootstrap'
import { Dot as IconDot } from 'react-bootstrap-icons' import { Dot as IconDot, Flag as IconFlag, PeopleFill as IconPeopleFill, Tag as IconTag } from 'react-bootstrap-icons'
import { Flag as IconFlag } from 'react-bootstrap-icons'
import { PeopleFill as IconPeopleFill } from 'react-bootstrap-icons'
import { Tag as IconTag } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
/** /**

View file

@ -4,25 +4,25 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { useOutlineButtonVariant } from '../../../hooks/dark-mode/use-outline-button-variant' import { useOutlineButtonVariant } from '../../../hooks/dark-mode/use-outline-button-variant'
import { UiIcon } from '../../common/icons/ui-icon' import { UiIcon } from '../../common/icons/ui-icon'
import Link from 'next/link' import Link from 'next/link'
import React from 'react' import React from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { FileEarmarkTextFill as IconFileEarmarkTextFill } from 'react-bootstrap-icons' import { FileEarmarkTextFill as IconFileEarmarkTextFill } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
/** /**
* Button that links to the read-only version of a note. * Button that links to the read-only version of a note.
*/ */
export const ReadOnlyModeButton: React.FC = () => { export const ReadOnlyModeButton: React.FC = () => {
const { t } = useTranslation()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const buttonVariant = useOutlineButtonVariant() const buttonVariant = useOutlineButtonVariant()
const buttonTitle = useTranslatedText('editor.documentBar.readOnlyMode')
return ( return (
<Link href={`/s/${noteIdentifier}`} target='_blank'> <Link href={`/s/${noteIdentifier}`} target='_blank'>
<Button title={t('editor.documentBar.readOnlyMode') ?? undefined} size='sm' variant={buttonVariant}> <Button title={buttonTitle} size='sm' variant={buttonVariant}>
<UiIcon icon={IconFileEarmarkTextFill} /> <UiIcon icon={IconFileEarmarkTextFill} />
</Button> </Button>
</Link> </Link>

View file

@ -4,25 +4,25 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { useOutlineButtonVariant } from '../../../hooks/dark-mode/use-outline-button-variant' import { useOutlineButtonVariant } from '../../../hooks/dark-mode/use-outline-button-variant'
import { UiIcon } from '../../common/icons/ui-icon' import { UiIcon } from '../../common/icons/ui-icon'
import Link from 'next/link' import Link from 'next/link'
import React from 'react' import React from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { Tv as IconTv } from 'react-bootstrap-icons' import { Tv as IconTv } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
/** /**
* Button that links to the slide-show presentation of the current note. * Button that links to the slide-show presentation of the current note.
*/ */
export const SlideModeButton: React.FC = () => { export const SlideModeButton: React.FC = () => {
const { t } = useTranslation()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const buttonVariant = useOutlineButtonVariant() const buttonVariant = useOutlineButtonVariant()
const buttonTitle = useTranslatedText('editor.documentBar.slideMode')
return ( return (
<Link href={`/p/${noteIdentifier}`} target='_blank'> <Link href={`/p/${noteIdentifier}`} target='_blank'>
<Button title={t('editor.documentBar.slideMode') ?? undefined} className='ms-2' size='sm' variant={buttonVariant}> <Button title={buttonTitle} className='ms-2' size='sm' variant={buttonVariant}>
<UiIcon icon={IconTv} /> <UiIcon icon={IconTv} />
</Button> </Button>
</Link> </Link>

View file

@ -5,10 +5,8 @@
*/ */
import type { ChangeSpec, Transaction } from '@codemirror/state' import type { ChangeSpec, Transaction } from '@codemirror/state'
import { Annotation } from '@codemirror/state' import { Annotation } from '@codemirror/state'
import type { EditorView, PluginValue } from '@codemirror/view' import type { EditorView, PluginValue, ViewUpdate } from '@codemirror/view'
import type { ViewUpdate } from '@codemirror/view' import type { Text as YText, Transaction as YTransaction, YTextEvent } from 'yjs'
import type { Text as YText } from 'yjs'
import type { Transaction as YTransaction, YTextEvent } from 'yjs'
const syncAnnotation = Annotation.define() const syncAnnotation = Annotation.define()

View file

@ -7,7 +7,7 @@ import { concatCssClasses } from '../../../../../utils/concat-css-classes'
import { createCursorCssClass } from './create-cursor-css-class' import { createCursorCssClass } from './create-cursor-css-class'
import styles from './style.module.scss' import styles from './style.module.scss'
import type { SelectionRange } from '@codemirror/state' import type { SelectionRange } from '@codemirror/state'
import type { LayerMarker, EditorView, Rect } from '@codemirror/view' import type { EditorView, LayerMarker, Rect } from '@codemirror/view'
import { Direction } from '@codemirror/view' import { Direction } from '@codemirror/view'
/** /**

View file

@ -6,6 +6,7 @@
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url' import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url'
import { useMayEdit } from '../../../hooks/common/use-may-edit' import { useMayEdit } from '../../../hooks/common/use-may-edit'
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { useDarkModeState } from '../../../hooks/dark-mode/use-dark-mode-state' import { useDarkModeState } from '../../../hooks/dark-mode/use-dark-mode-state'
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute' import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/_base-classes/code-block-markdown-extension/find-language-by-code-block-name' import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/_base-classes/code-block-markdown-extension/find-language-by-code-block-name'
@ -42,7 +43,6 @@ import { lintGutter } from '@codemirror/lint'
import { oneDark } from '@codemirror/theme-one-dark' import { oneDark } from '@codemirror/theme-one-dark'
import ReactCodeMirror from '@uiw/react-codemirror' import ReactCodeMirror from '@uiw/react-codemirror'
import React, { useEffect, useMemo } from 'react' import React, { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export type EditorPaneProps = ScrollProps export type EditorPaneProps = ScrollProps
@ -129,7 +129,6 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, o
[ligaturesEnabled] [ligaturesEnabled]
) )
const { t } = useTranslation()
const darkModeActivated = useDarkModeState() const darkModeActivated = useDarkModeState()
const editorOrigin = useBaseUrl(ORIGIN.EDITOR) const editorOrigin = useBaseUrl(ORIGIN.EDITOR)
const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced) const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced)
@ -142,6 +141,9 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, o
} }
}, [messageTransporter]) }, [messageTransporter])
const translateOptions = useMemo(() => ({ host: editorOrigin }), [editorOrigin])
const placeholderText = useTranslatedText('editor.placeholder', translateOptions)
return ( return (
<div <div
className={`d-flex flex-column h-100 position-relative`} className={`d-flex flex-column h-100 position-relative`}
@ -153,7 +155,7 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, o
<ToolBar /> <ToolBar />
<ReactCodeMirror <ReactCodeMirror
editable={updateViewContextExtension !== null && isSynced && mayEdit} editable={updateViewContextExtension !== null && isSynced && mayEdit}
placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''} placeholder={placeholderText}
extensions={extensions} extensions={extensions}
width={'100%'} width={'100%'}
height={'100%'} height={'100%'}

View file

@ -3,8 +3,8 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { ConnectionState } from '@hedgedoc/commons'
import type { TransportAdapter } from '@hedgedoc/commons' import type { TransportAdapter } from '@hedgedoc/commons'
import { ConnectionState } from '@hedgedoc/commons'
import type { Message, MessageType } from '@hedgedoc/commons/dist' import type { Message, MessageType } from '@hedgedoc/commons/dist'
/** /**

View file

@ -7,8 +7,7 @@ import { useApplicationState } from '../../../../../hooks/common/use-application
import { YTextSyncViewPlugin } from '../../codemirror-extensions/document-sync/y-text-sync-view-plugin' import { YTextSyncViewPlugin } from '../../codemirror-extensions/document-sync/y-text-sync-view-plugin'
import type { Extension } from '@codemirror/state' import type { Extension } from '@codemirror/state'
import { ViewPlugin } from '@codemirror/view' import { ViewPlugin } from '@codemirror/view'
import type { YDocSyncClientAdapter } from '@hedgedoc/commons' import type { RealtimeDoc, YDocSyncClientAdapter } from '@hedgedoc/commons'
import type { RealtimeDoc } from '@hedgedoc/commons'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
/** /**

View file

@ -7,8 +7,7 @@ import fontStyles from '../../../../../../global-styles/variables.module.scss'
import { useDarkModeState } from '../../../../../hooks/dark-mode/use-dark-mode-state' import { useDarkModeState } from '../../../../../hooks/dark-mode/use-dark-mode-state'
import styles from './emoji-picker.module.scss' import styles from './emoji-picker.module.scss'
import { Picker } from 'emoji-picker-element' import { Picker } from 'emoji-picker-element'
import type { EmojiClickEvent, EmojiClickEventDetail } from 'emoji-picker-element/shared' import type { EmojiClickEvent, EmojiClickEventDetail, PickerConstructorOptions } from 'emoji-picker-element/shared'
import type { PickerConstructorOptions } from 'emoji-picker-element/shared'
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import { Popover } from 'react-bootstrap' import { Popover } from 'react-bootstrap'
import type { PopoverProps } from 'react-bootstrap/Popover' import type { PopoverProps } from 'react-bootstrap/Popover'

View file

@ -3,6 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../../../hooks/common/use-translated-text'
import { cypressId } from '../../../../../utils/cypress-attribute' import { cypressId } from '../../../../../utils/cypress-attribute'
import { UiIcon } from '../../../../common/icons/ui-icon' import { UiIcon } from '../../../../common/icons/ui-icon'
import { CommonModal } from '../../../../common/modals/common-modal' import { CommonModal } from '../../../../common/modals/common-modal'
@ -10,9 +11,8 @@ import type { TableSize } from './table-size-picker-popover'
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { Button, Form, ModalFooter } from 'react-bootstrap' import { Button, Form, ModalFooter } from 'react-bootstrap'
import { Table as IconTable } from 'react-bootstrap-icons' import { Table as IconTable, X as IconX } from 'react-bootstrap-icons'
import { X as IconX } from 'react-bootstrap-icons' import { Trans } from 'react-i18next'
import { Trans, useTranslation } from 'react-i18next'
export interface CustomTableSizeModalProps { export interface CustomTableSizeModalProps {
showModal: boolean showModal: boolean
@ -33,7 +33,6 @@ const initialTableSize: TableSize = {
* @param onSizeSelect is called if the user entered and confirmed a custom table size * @param onSizeSelect is called if the user entered and confirmed a custom table size
*/ */
export const CustomTableSizeModal: React.FC<CustomTableSizeModalProps> = ({ showModal, onDismiss, onSizeSelect }) => { export const CustomTableSizeModal: React.FC<CustomTableSizeModalProps> = ({ showModal, onDismiss, onSizeSelect }) => {
const { t } = useTranslation()
const [tableSize, setTableSize] = useState<TableSize>(() => initialTableSize) const [tableSize, setTableSize] = useState<TableSize>(() => initialTableSize)
useEffect(() => { useEffect(() => {
@ -63,6 +62,9 @@ export const CustomTableSizeModal: React.FC<CustomTableSizeModalProps> = ({ show
})) }))
}, []) }, [])
const columnPlaceholderText = useTranslatedText('editor.editorToolbar.table.cols')
const rowsPlaceholderText = useTranslatedText('editor.editorToolbar.table.rows')
return ( return (
<CommonModal <CommonModal
show={showModal} show={showModal}
@ -75,7 +77,7 @@ export const CustomTableSizeModal: React.FC<CustomTableSizeModalProps> = ({ show
<Form.Control <Form.Control
type={'number'} type={'number'}
min={1} min={1}
placeholder={t('editor.editorToolbar.table.cols') ?? undefined} placeholder={columnPlaceholderText}
isInvalid={tableSize.columns <= 0} isInvalid={tableSize.columns <= 0}
onChange={onColChange} onChange={onColChange}
/> />
@ -83,7 +85,7 @@ export const CustomTableSizeModal: React.FC<CustomTableSizeModalProps> = ({ show
<Form.Control <Form.Control
type={'number'} type={'number'}
min={1} min={1}
placeholder={t('editor.editorToolbar.table.rows') ?? undefined} placeholder={rowsPlaceholderText}
isInvalid={tableSize.rows <= 0} isInvalid={tableSize.rows <= 0}
onChange={onRowChange} onChange={onRowChange}
/> />

View file

@ -61,7 +61,7 @@ export const TableSizePickerPopover = React.forwardRef<HTMLDivElement, TableSize
{...cypressAttribute('col', `${col + 1}`)} {...cypressAttribute('col', `${col + 1}`)}
{...cypressAttribute('row', `${row + 1}`)} {...cypressAttribute('row', `${row + 1}`)}
onMouseEnter={onSizeHover(row + 1, col + 1)} onMouseEnter={onSizeHover(row + 1, col + 1)}
title={t('editor.editorToolbar.table.titleWithSize', { cols: col + 1, rows: row + 1 }) ?? undefined} title={t('editor.editorToolbar.table.titleWithSize', { cols: col + 1, rows: row + 1 })}
onClick={() => onTableSizeSelected(row + 1, col + 1)} onClick={() => onTableSizeSelected(row + 1, col + 1)}
/> />
) )

View file

@ -4,8 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { getGlobalState } from '../../../../redux' import { getGlobalState } from '../../../../redux'
import type { ScrollState } from '../../synced-scroll/scroll-props' import type { ScrollCallback, ScrollState } from '../../synced-scroll/scroll-props'
import type { ScrollCallback } from '../../synced-scroll/scroll-props'
import { useMemo } from 'react' import { useMemo } from 'react'
/** /**

View file

@ -10,7 +10,7 @@ import { mockI18n } from '../../../../../../test-utils/mock-i18n'
import { mockNoteOwnership } from '../../../../../../test-utils/note-ownership' import { mockNoteOwnership } from '../../../../../../test-utils/note-ownership'
import * as useUiNotificationsModule from '../../../../../notifications/ui-notification-boundary' import * as useUiNotificationsModule from '../../../../../notifications/ui-notification-boundary'
import { AliasesAddForm } from './aliases-add-form' import { AliasesAddForm } from './aliases-add-form'
import { render, act, screen } from '@testing-library/react' import { act, render, screen } from '@testing-library/react'
import testEvent from '@testing-library/user-event' import testEvent from '@testing-library/user-event'
import React from 'react' import React from 'react'

View file

@ -7,6 +7,7 @@ import { addAlias } from '../../../../../../api/alias'
import { useApplicationState } from '../../../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
import { useIsOwner } from '../../../../../../hooks/common/use-is-owner' import { useIsOwner } from '../../../../../../hooks/common/use-is-owner'
import { useOnInputChange } from '../../../../../../hooks/common/use-on-input-change' import { useOnInputChange } from '../../../../../../hooks/common/use-on-input-change'
import { useTranslatedText } from '../../../../../../hooks/common/use-translated-text'
import { updateMetadata } from '../../../../../../redux/note-details/methods' import { updateMetadata } from '../../../../../../redux/note-details/methods'
import { testId } from '../../../../../../utils/test-id' import { testId } from '../../../../../../utils/test-id'
import { UiIcon } from '../../../../../common/icons/ui-icon' import { UiIcon } from '../../../../../common/icons/ui-icon'
@ -15,7 +16,6 @@ import type { FormEvent } from 'react'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Button, Form, InputGroup } from 'react-bootstrap' import { Button, Form, InputGroup } from 'react-bootstrap'
import { Plus as IconPlus } from 'react-bootstrap-icons' import { Plus as IconPlus } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
const validAliasRegex = /^[a-z0-9_-]*$/ const validAliasRegex = /^[a-z0-9_-]*$/
@ -23,7 +23,6 @@ const validAliasRegex = /^[a-z0-9_-]*$/
* Form for adding a new alias to a note. * Form for adding a new alias to a note.
*/ */
export const AliasesAddForm: React.FC = () => { export const AliasesAddForm: React.FC = () => {
const { t } = useTranslation()
const { showErrorNotification } = useUiNotifications() const { showErrorNotification } = useUiNotifications()
const noteId = useApplicationState((state) => state.noteDetails.id) const noteId = useApplicationState((state) => state.noteDetails.id)
const isOwner = useIsOwner() const isOwner = useIsOwner()
@ -48,12 +47,14 @@ export const AliasesAddForm: React.FC = () => {
return validAliasRegex.test(newAlias) return validAliasRegex.test(newAlias)
}, [newAlias]) }, [newAlias])
const addAliasText = useTranslatedText('editor.modal.aliases.addAlias')
return ( return (
<form onSubmit={onAddAlias}> <form onSubmit={onAddAlias}>
<InputGroup className={'me-1 mb-1'} hasValidation={true}> <InputGroup className={'me-1 mb-1'} hasValidation={true}>
<Form.Control <Form.Control
value={newAlias} value={newAlias}
placeholder={t('editor.modal.aliases.addAlias') ?? undefined} placeholder={addAliasText}
onChange={onNewAliasInputChange} onChange={onNewAliasInputChange}
isInvalid={!newAliasValid} isInvalid={!newAliasValid}
disabled={!isOwner} disabled={!isOwner}
@ -65,7 +66,7 @@ export const AliasesAddForm: React.FC = () => {
variant='light' variant='light'
className={'text-secondary ms-2'} className={'text-secondary ms-2'}
disabled={!isOwner || !newAliasValid || newAlias === ''} disabled={!isOwner || !newAliasValid || newAlias === ''}
title={t('editor.modal.aliases.addAlias') ?? undefined} title={addAliasText}
{...testId('addAliasButton')}> {...testId('addAliasButton')}>
<UiIcon icon={IconPlus} /> <UiIcon icon={IconPlus} />
</Button> </Button>

View file

@ -10,7 +10,7 @@ import { mockI18n } from '../../../../../../test-utils/mock-i18n'
import { mockNoteOwnership } from '../../../../../../test-utils/note-ownership' import { mockNoteOwnership } from '../../../../../../test-utils/note-ownership'
import * as useUiNotificationsModule from '../../../../../notifications/ui-notification-boundary' import * as useUiNotificationsModule from '../../../../../notifications/ui-notification-boundary'
import { AliasesListEntry } from './aliases-list-entry' import { AliasesListEntry } from './aliases-list-entry'
import { render, act, screen } from '@testing-library/react' import { act, render, screen } from '@testing-library/react'
import React from 'react' import React from 'react'
jest.mock('../../../../../../api/alias') jest.mock('../../../../../../api/alias')

View file

@ -6,6 +6,7 @@
import { deleteAlias, markAliasAsPrimary } from '../../../../../../api/alias' import { deleteAlias, markAliasAsPrimary } from '../../../../../../api/alias'
import type { Alias } from '../../../../../../api/alias/types' import type { Alias } from '../../../../../../api/alias/types'
import { useIsOwner } from '../../../../../../hooks/common/use-is-owner' import { useIsOwner } from '../../../../../../hooks/common/use-is-owner'
import { useTranslatedText } from '../../../../../../hooks/common/use-translated-text'
import { updateMetadata } from '../../../../../../redux/note-details/methods' import { updateMetadata } from '../../../../../../redux/note-details/methods'
import { testId } from '../../../../../../utils/test-id' import { testId } from '../../../../../../utils/test-id'
import { UiIcon } from '../../../../../common/icons/ui-icon' import { UiIcon } from '../../../../../common/icons/ui-icon'
@ -13,9 +14,7 @@ import { ShowIf } from '../../../../../common/show-if/show-if'
import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary' import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { StarFill as IconStarFill } from 'react-bootstrap-icons' import { Star as IconStar, StarFill as IconStarFill, X as IconX } from 'react-bootstrap-icons'
import { Star as IconStar } from 'react-bootstrap-icons'
import { X as IconX } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export interface AliasesListEntryProps { export interface AliasesListEntryProps {
@ -44,6 +43,10 @@ export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) =>
.catch(showErrorNotification(t('editor.modal.aliases.errorMakingPrimary'))) .catch(showErrorNotification(t('editor.modal.aliases.errorMakingPrimary')))
}, [alias, t, showErrorNotification]) }, [alias, t, showErrorNotification])
const isPrimaryText = useTranslatedText('editor.modal.aliases.isPrimary')
const makePrimaryText = useTranslatedText('editor.modal.aliases.makePrimary')
const removeAliasText = useTranslatedText('editor.modal.aliases.removeAlias')
return ( return (
<li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}> <li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}>
{alias.name} {alias.name}
@ -53,7 +56,7 @@ export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) =>
className={'me-2 text-warning'} className={'me-2 text-warning'}
variant='light' variant='light'
disabled={true} disabled={true}
title={t('editor.modal.aliases.isPrimary') ?? undefined} title={isPrimaryText}
{...testId('aliasIsPrimary')}> {...testId('aliasIsPrimary')}>
<UiIcon icon={IconStar} /> <UiIcon icon={IconStar} />
</Button> </Button>
@ -63,7 +66,7 @@ export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) =>
className={'me-2'} className={'me-2'}
variant='light' variant='light'
disabled={!isOwner} disabled={!isOwner}
title={t('editor.modal.aliases.makePrimary') ?? undefined} title={makePrimaryText}
onClick={onMakePrimaryClick} onClick={onMakePrimaryClick}
{...testId('aliasButtonMakePrimary')}> {...testId('aliasButtonMakePrimary')}>
<UiIcon icon={IconStarFill} /> <UiIcon icon={IconStarFill} />
@ -73,7 +76,7 @@ export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) =>
variant='light' variant='light'
className={'text-danger'} className={'text-danger'}
disabled={!isOwner} disabled={!isOwner}
title={t('editor.modal.aliases.removeAlias') ?? undefined} title={removeAliasText}
onClick={onRemoveClick} onClick={onRemoveClick}
{...testId('aliasButtonRemove')}> {...testId('aliasButtonRemove')}>
<UiIcon icon={IconX} /> <UiIcon icon={IconX} />

View file

@ -7,8 +7,8 @@ import type { Alias } from '../../../../../../api/alias/types'
import * as useApplicationStateModule from '../../../../../../hooks/common/use-application-state' import * as useApplicationStateModule from '../../../../../../hooks/common/use-application-state'
import { mockI18n } from '../../../../../../test-utils/mock-i18n' import { mockI18n } from '../../../../../../test-utils/mock-i18n'
import { AliasesList } from './aliases-list' import { AliasesList } from './aliases-list'
import * as AliasesListEntryModule from './aliases-list-entry'
import type { AliasesListEntryProps } from './aliases-list-entry' import type { AliasesListEntryProps } from './aliases-list-entry'
import * as AliasesListEntryModule from './aliases-list-entry'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import React from 'react' import React from 'react'

View file

@ -11,8 +11,8 @@ import * as AliasesAddFormModule from './aliases-add-form'
import * as AliasesListModule from './aliases-list' import * as AliasesListModule from './aliases-list'
import { AliasesModal } from './aliases-modal' import { AliasesModal } from './aliases-modal'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import React from 'react'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React from 'react'
jest.mock('./aliases-list') jest.mock('./aliases-list')
jest.mock('./aliases-add-form') jest.mock('./aliases-add-form')

View file

@ -11,10 +11,12 @@ import type { SpecificSidebarMenuProps } from '../types'
import { DocumentSidebarMenuSelection } from '../types' import { DocumentSidebarMenuSelection } from '../types'
import { ExportMarkdownSidebarEntry } from './export-markdown-sidebar-entry' import { ExportMarkdownSidebarEntry } from './export-markdown-sidebar-entry'
import React, { Fragment, useCallback } from 'react' import React, { Fragment, useCallback } from 'react'
import { ArrowLeft as IconArrowLeft } from 'react-bootstrap-icons' import {
import { CloudDownload as IconCloudDownload } from 'react-bootstrap-icons' ArrowLeft as IconArrowLeft,
import { FileCode as IconFileCode } from 'react-bootstrap-icons' CloudDownload as IconCloudDownload,
import { Github as IconGithub } from 'react-bootstrap-icons' FileCode as IconFileCode,
Github as IconGithub
} from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
/** /**

View file

@ -11,10 +11,12 @@ import type { SpecificSidebarMenuProps } from '../types'
import { DocumentSidebarMenuSelection } from '../types' import { DocumentSidebarMenuSelection } from '../types'
import { ImportMarkdownSidebarEntry } from './import-markdown-sidebar-entry' import { ImportMarkdownSidebarEntry } from './import-markdown-sidebar-entry'
import React, { Fragment, useCallback } from 'react' import React, { Fragment, useCallback } from 'react'
import { ArrowLeft as IconArrowLeft } from 'react-bootstrap-icons' import {
import { Clipboard as IconClipboard } from 'react-bootstrap-icons' ArrowLeft as IconArrowLeft,
import { CloudUpload as IconCloudUpload } from 'react-bootstrap-icons' Clipboard as IconClipboard,
import { Github as IconGithub } from 'react-bootstrap-icons' CloudUpload as IconCloudUpload,
Github as IconGithub
} from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
/** /**

View file

@ -4,12 +4,12 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useLowercaseOnInputChange } from '../../../../../../hooks/common/use-lowercase-on-input-change' import { useLowercaseOnInputChange } from '../../../../../../hooks/common/use-lowercase-on-input-change'
import { useTranslatedText } from '../../../../../../hooks/common/use-translated-text'
import { UiIcon } from '../../../../../common/icons/ui-icon' import { UiIcon } from '../../../../../common/icons/ui-icon'
import type { PermissionDisabledProps } from './permission-disabled.prop' import type { PermissionDisabledProps } from './permission-disabled.prop'
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { Button, FormControl, InputGroup } from 'react-bootstrap' import { Button, FormControl, InputGroup } from 'react-bootstrap'
import { Plus as IconPlus } from 'react-bootstrap-icons' import { Plus as IconPlus } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
export interface PermissionAddEntryFieldProps { export interface PermissionAddEntryFieldProps {
onAddEntry: (identifier: string) => void onAddEntry: (identifier: string) => void
@ -28,8 +28,6 @@ export const PermissionAddEntryField: React.FC<PermissionAddEntryFieldProps & Pe
i18nKey, i18nKey,
disabled disabled
}) => { }) => {
const { t } = useTranslation()
const [newEntryIdentifier, setNewEntryIdentifier] = useState('') const [newEntryIdentifier, setNewEntryIdentifier] = useState('')
const onChange = useLowercaseOnInputChange(setNewEntryIdentifier) const onChange = useLowercaseOnInputChange(setNewEntryIdentifier)
@ -37,19 +35,16 @@ export const PermissionAddEntryField: React.FC<PermissionAddEntryFieldProps & Pe
onAddEntry(newEntryIdentifier) onAddEntry(newEntryIdentifier)
}, [newEntryIdentifier, onAddEntry]) }, [newEntryIdentifier, onAddEntry])
const placeholderText = useTranslatedText(i18nKey)
return ( return (
<li className={'list-group-item'}> <li className={'list-group-item'}>
<InputGroup className={'me-1 mb-1'}> <InputGroup className={'me-1 mb-1'}>
<FormControl <FormControl value={newEntryIdentifier} placeholder={placeholderText} onChange={onChange} disabled={disabled} />
value={newEntryIdentifier}
placeholder={t(i18nKey) ?? undefined}
onChange={onChange}
disabled={disabled}
/>
<Button <Button
variant='light' variant='light'
className={'text-secondary ms-2'} className={'text-secondary ms-2'}
title={t(i18nKey) ?? undefined} title={placeholderText}
onClick={onSubmit} onClick={onSubmit}
disabled={disabled}> disabled={disabled}>
<UiIcon icon={IconPlus} /> <UiIcon icon={IconPlus} />

View file

@ -3,15 +3,13 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../../../../hooks/common/use-translated-text'
import { UiIcon } from '../../../../../common/icons/ui-icon' import { UiIcon } from '../../../../../common/icons/ui-icon'
import type { PermissionDisabledProps } from './permission-disabled.prop' import type { PermissionDisabledProps } from './permission-disabled.prop'
import { AccessLevel } from '@hedgedoc/commons' import { AccessLevel } from '@hedgedoc/commons'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Button, ToggleButtonGroup } from 'react-bootstrap' import { Button, ToggleButtonGroup } from 'react-bootstrap'
import { Eye as IconEye } from 'react-bootstrap-icons' import { Eye as IconEye, Pencil as IconPencil, X as IconX } from 'react-bootstrap-icons'
import { Pencil as IconPencil } from 'react-bootstrap-icons'
import { X as IconX } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
interface PermissionEntryButtonI18nKeys { interface PermissionEntryButtonI18nKeys {
remove: string remove: string
@ -53,8 +51,6 @@ export const PermissionEntryButtons: React.FC<PermissionEntryButtonsProps & Perm
onRemove, onRemove,
disabled disabled
}) => { }) => {
const { t } = useTranslation()
const i18nKeys: PermissionEntryButtonI18nKeys = useMemo(() => { const i18nKeys: PermissionEntryButtonI18nKeys = useMemo(() => {
switch (type) { switch (type) {
case PermissionType.USER: case PermissionType.USER:
@ -72,27 +68,27 @@ export const PermissionEntryButtons: React.FC<PermissionEntryButtonsProps & Perm
} }
}, [type]) }, [type])
const translateOptions = useMemo(() => ({ name }), [name])
const removeTitle = useTranslatedText(i18nKeys.remove, translateOptions)
const setReadOnlyTitle = useTranslatedText(i18nKeys.setReadOnly, translateOptions)
const setWritableTitle = useTranslatedText(i18nKeys.setWriteable, translateOptions)
return ( return (
<div> <div>
<Button <Button variant='light' className={'text-danger me-2'} disabled={disabled} title={removeTitle} onClick={onRemove}>
variant='light'
className={'text-danger me-2'}
disabled={disabled}
title={t(i18nKeys.remove, { name }) ?? undefined}
onClick={onRemove}>
<UiIcon icon={IconX} /> <UiIcon icon={IconX} />
</Button> </Button>
<ToggleButtonGroup type='radio' name='edit-mode' value={currentSetting}> <ToggleButtonGroup type='radio' name='edit-mode' value={currentSetting}>
<Button <Button
disabled={disabled} disabled={disabled}
title={t(i18nKeys.setReadOnly, { name }) ?? undefined} title={setReadOnlyTitle}
variant={currentSetting === AccessLevel.READ_ONLY ? 'secondary' : 'outline-secondary'} variant={currentSetting === AccessLevel.READ_ONLY ? 'secondary' : 'outline-secondary'}
onClick={onSetReadOnly}> onClick={onSetReadOnly}>
<UiIcon icon={IconEye} /> <UiIcon icon={IconEye} />
</Button> </Button>
<Button <Button
disabled={disabled} disabled={disabled}
title={t(i18nKeys.setWriteable, { name }) ?? undefined} title={setWritableTitle}
variant={currentSetting === AccessLevel.WRITEABLE ? 'secondary' : 'outline-secondary'} variant={currentSetting === AccessLevel.WRITEABLE ? 'secondary' : 'outline-secondary'}
onClick={onSetWriteable}> onClick={onSetWriteable}>
<UiIcon icon={IconPencil} /> <UiIcon icon={IconPencil} />

View file

@ -5,6 +5,7 @@
*/ */
import { removeGroupPermission, setGroupPermission } from '../../../../../../api/permissions' import { removeGroupPermission, setGroupPermission } from '../../../../../../api/permissions'
import { useApplicationState } from '../../../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
import { useTranslatedText } from '../../../../../../hooks/common/use-translated-text'
import { setNotePermissionsFromServer } from '../../../../../../redux/note-details/methods' import { setNotePermissionsFromServer } from '../../../../../../redux/note-details/methods'
import { IconButton } from '../../../../../common/icon-button/icon-button' import { IconButton } from '../../../../../common/icon-button/icon-button'
import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary' import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary'
@ -12,9 +13,7 @@ import type { PermissionDisabledProps } from './permission-disabled.prop'
import { AccessLevel, SpecialGroup } from '@hedgedoc/commons' import { AccessLevel, SpecialGroup } from '@hedgedoc/commons'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { ToggleButtonGroup } from 'react-bootstrap' import { ToggleButtonGroup } from 'react-bootstrap'
import { Eye as IconEye } from 'react-bootstrap-icons' import { Eye as IconEye, Pencil as IconPencil, SlashCircle as IconSlashCircle } from 'react-bootstrap-icons'
import { Pencil as IconPencil } from 'react-bootstrap-icons'
import { SlashCircle as IconSlashCircle } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export interface PermissionEntrySpecialGroupProps { export interface PermissionEntrySpecialGroupProps {
@ -71,6 +70,11 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
} }
}, [type, t]) }, [type, t])
const translateOptions = useMemo(() => ({ name }), [name])
const denyGroupText = useTranslatedText('editor.modal.permissions.denyGroup', translateOptions)
const viewOnlyGroupText = useTranslatedText('editor.modal.permissions.viewOnlyGroup', translateOptions)
const editGroupText = useTranslatedText('editor.modal.permissions.editGroup', translateOptions)
return ( return (
<li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}> <li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}>
<span>{name}</span> <span>{name}</span>
@ -78,7 +82,7 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
<ToggleButtonGroup type='radio' name='edit-mode'> <ToggleButtonGroup type='radio' name='edit-mode'>
<IconButton <IconButton
icon={IconSlashCircle} icon={IconSlashCircle}
title={t('editor.modal.permissions.denyGroup', { name }) ?? undefined} title={denyGroupText}
variant={level === AccessLevel.NONE ? 'secondary' : 'outline-secondary'} variant={level === AccessLevel.NONE ? 'secondary' : 'outline-secondary'}
onClick={onSetEntryDenied} onClick={onSetEntryDenied}
disabled={disabled} disabled={disabled}
@ -86,7 +90,7 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
/> />
<IconButton <IconButton
icon={IconEye} icon={IconEye}
title={t('editor.modal.permissions.viewOnlyGroup', { name }) ?? undefined} title={viewOnlyGroupText}
variant={level === AccessLevel.READ_ONLY ? 'secondary' : 'outline-secondary'} variant={level === AccessLevel.READ_ONLY ? 'secondary' : 'outline-secondary'}
onClick={onSetEntryReadOnly} onClick={onSetEntryReadOnly}
disabled={disabled} disabled={disabled}
@ -94,7 +98,7 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
/> />
<IconButton <IconButton
icon={IconPencil} icon={IconPencil}
title={t('editor.modal.permissions.editGroup', { name }) ?? undefined} title={editGroupText}
variant={level === AccessLevel.WRITEABLE ? 'secondary' : 'outline-secondary'} variant={level === AccessLevel.WRITEABLE ? 'secondary' : 'outline-secondary'}
onClick={onSetEntryWriteable} onClick={onSetEntryWriteable}
disabled={disabled} disabled={disabled}

View file

@ -4,11 +4,11 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useOnInputChange } from '../../../../../../hooks/common/use-on-input-change' import { useOnInputChange } from '../../../../../../hooks/common/use-on-input-change'
import { useTranslatedText } from '../../../../../../hooks/common/use-translated-text'
import { UiIcon } from '../../../../../common/icons/ui-icon' import { UiIcon } from '../../../../../common/icons/ui-icon'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Button, FormControl, InputGroup } from 'react-bootstrap' import { Button, FormControl, InputGroup } from 'react-bootstrap'
import { Check as IconCheck } from 'react-bootstrap-icons' import { Check as IconCheck } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
export interface PermissionOwnerChangeProps { export interface PermissionOwnerChangeProps {
onConfirmOwnerChange: (newOwner: string) => void onConfirmOwnerChange: (newOwner: string) => void
@ -20,7 +20,6 @@ export interface PermissionOwnerChangeProps {
* @param onConfirmOwnerChange The callback to call if the owner was changed. * @param onConfirmOwnerChange The callback to call if the owner was changed.
*/ */
export const PermissionOwnerChange: React.FC<PermissionOwnerChangeProps> = ({ onConfirmOwnerChange }) => { export const PermissionOwnerChange: React.FC<PermissionOwnerChangeProps> = ({ onConfirmOwnerChange }) => {
const { t } = useTranslation()
const [ownerFieldValue, setOwnerFieldValue] = useState('') const [ownerFieldValue, setOwnerFieldValue] = useState('')
const onChangeField = useOnInputChange(setOwnerFieldValue) const onChangeField = useOnInputChange(setOwnerFieldValue)
@ -32,16 +31,15 @@ export const PermissionOwnerChange: React.FC<PermissionOwnerChangeProps> = ({ on
return ownerFieldValue.trim() === '' return ownerFieldValue.trim() === ''
}, [ownerFieldValue]) }, [ownerFieldValue])
const placeholderText = useTranslatedText('editor.modal.permissions.ownerChange.placeholder')
const buttonTitleText = useTranslatedText('common.save')
return ( return (
<InputGroup className={'me-1 mb-1'}> <InputGroup className={'me-1 mb-1'}>
<FormControl <FormControl value={ownerFieldValue} placeholder={placeholderText} onChange={onChangeField} />
value={ownerFieldValue}
placeholder={t('editor.modal.permissions.ownerChange.placeholder') ?? undefined}
onChange={onChangeField}
/>
<Button <Button
variant='light' variant='light'
title={t('common.save') ?? undefined} title={buttonTitleText}
onClick={onClickConfirm} onClick={onClickConfirm}
className={'ms-2'} className={'ms-2'}
disabled={confirmButtonDisabled}> disabled={confirmButtonDisabled}>

View file

@ -4,13 +4,13 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from '../../../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
import { useTranslatedText } from '../../../../../../hooks/common/use-translated-text'
import { UiIcon } from '../../../../../common/icons/ui-icon' import { UiIcon } from '../../../../../common/icons/ui-icon'
import { UserAvatarForUsername } from '../../../../../common/user-avatar/user-avatar-for-username' import { UserAvatarForUsername } from '../../../../../common/user-avatar/user-avatar-for-username'
import type { PermissionDisabledProps } from './permission-disabled.prop' import type { PermissionDisabledProps } from './permission-disabled.prop'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { Pencil as IconPencil } from 'react-bootstrap-icons' import { Pencil as IconPencil } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
export interface PermissionOwnerInfoProps { export interface PermissionOwnerInfoProps {
onEditOwner: () => void onEditOwner: () => void
@ -26,17 +26,13 @@ export const PermissionOwnerInfo: React.FC<PermissionOwnerInfoProps & Permission
onEditOwner, onEditOwner,
disabled disabled
}) => { }) => {
const { t } = useTranslation()
const noteOwner = useApplicationState((state) => state.noteDetails.permissions.owner) const noteOwner = useApplicationState((state) => state.noteDetails.permissions.owner)
const buttonTitle = useTranslatedText('editor.modal.permissions.ownerChange.button')
return ( return (
<Fragment> <Fragment>
<UserAvatarForUsername username={noteOwner} /> <UserAvatarForUsername username={noteOwner} />
<Button <Button variant='light' disabled={disabled} title={buttonTitle} onClick={onEditOwner}>
variant='light'
disabled={disabled}
title={t('editor.modal.permissions.ownerChange.button') ?? undefined}
onClick={onEditOwner}>
<UiIcon icon={IconPencil} /> <UiIcon icon={IconPencil} />
</Button> </Button>
</Fragment> </Fragment>

View file

@ -14,10 +14,12 @@ import { getUserDataForRevision } from './utils'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { ListGroup } from 'react-bootstrap' import { ListGroup } from 'react-bootstrap'
import { Clock as IconClock } from 'react-bootstrap-icons' import {
import { FileText as IconFileText } from 'react-bootstrap-icons' Clock as IconClock,
import { Person as IconPerson } from 'react-bootstrap-icons' FileText as IconFileText,
import { PersonPlus as IconPersonPlus } from 'react-bootstrap-icons' Person as IconPerson,
PersonPlus as IconPersonPlus
} from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { useAsync } from 'react-use' import { useAsync } from 'react-use'

View file

@ -13,8 +13,7 @@ import styles from './online-counter.module.scss'
import { OwnUserLine } from './own-user-line' import { OwnUserLine } from './own-user-line'
import { UserLine } from './user-line/user-line' import { UserLine } from './user-line/user-line'
import React, { Fragment, useCallback, useEffect, useMemo, useRef } from 'react' import React, { Fragment, useCallback, useEffect, useMemo, useRef } from 'react'
import { ArrowLeft as IconArrowLeft } from 'react-bootstrap-icons' import { ArrowLeft as IconArrowLeft, People as IconPeople } from 'react-bootstrap-icons'
import { People as IconPeople } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
/** /**

View file

@ -9,9 +9,11 @@ import { UiIcon } from '../../../common/icons/ui-icon'
import styles from './split-divider.module.scss' import styles from './split-divider.module.scss'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { ArrowLeftRight as IconArrowLeftRight } from 'react-bootstrap-icons' import {
import { ArrowLeft as IconArrowLeft } from 'react-bootstrap-icons' ArrowLeft as IconArrowLeft,
import { ArrowRight as IconArrowRight } from 'react-bootstrap-icons' ArrowLeftRight as IconArrowLeftRight,
ArrowRight as IconArrowRight
} from 'react-bootstrap-icons'
export enum DividerButtonsShift { export enum DividerButtonsShift {
SHIFT_TO_LEFT = 'shift-left', SHIFT_TO_LEFT = 'shift-left',

View file

@ -7,7 +7,7 @@ import { ShowIf } from '../../common/show-if/show-if'
import { useKeyboardShortcuts } from './hooks/use-keyboard-shortcuts' import { useKeyboardShortcuts } from './hooks/use-keyboard-shortcuts'
import { DividerButtonsShift, SplitDivider } from './split-divider/split-divider' import { DividerButtonsShift, SplitDivider } from './split-divider/split-divider'
import styles from './splitter.module.scss' import styles from './splitter.module.scss'
import type { ReactElement, TouchEvent, MouseEvent } from 'react' import type { MouseEvent, ReactElement, TouchEvent } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export interface SplitterProps { export interface SplitterProps {

View file

@ -13,9 +13,7 @@ import styles from './entry-menu.module.scss'
import { RemoveNoteEntryItem } from './remove-note-entry-item' import { RemoveNoteEntryItem } from './remove-note-entry-item'
import React from 'react' import React from 'react'
import { Dropdown } from 'react-bootstrap' import { Dropdown } from 'react-bootstrap'
import { Cloud as IconCloud } from 'react-bootstrap-icons' import { Cloud as IconCloud, Laptop as IconLaptop, ThreeDots as IconThreeDots } from 'react-bootstrap-icons'
import { Laptop as IconLaptop } from 'react-bootstrap-icons'
import { ThreeDots as IconThreeDots } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
export interface EntryMenuProps { export interface EntryMenuProps {

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useBooleanState } from '../../../hooks/common/use-boolean-state' import { useBooleanState } from '../../../hooks/common/use-boolean-state'
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { deleteAllHistoryEntries } from '../../../redux/history/methods' import { deleteAllHistoryEntries } from '../../../redux/history/methods'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import { UiIcon } from '../../common/icons/ui-icon' import { UiIcon } from '../../common/icons/ui-icon'
@ -13,14 +14,13 @@ import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-his
import React, { Fragment, useCallback } from 'react' import React, { Fragment, useCallback } from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { Trash as IconTrash } from 'react-bootstrap-icons' import { Trash as IconTrash } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
/** /**
* Renders a button to clear the complete history of the user. * Renders a button to clear the complete history of the user.
* A confirmation modal will be presented to the user after clicking the button. * A confirmation modal will be presented to the user after clicking the button.
*/ */
export const ClearHistoryButton: React.FC = () => { export const ClearHistoryButton: React.FC = () => {
const { t } = useTranslation()
const [modalVisibility, showModal, closeModal] = useBooleanState() const [modalVisibility, showModal, closeModal] = useBooleanState()
const { showErrorNotification } = useUiNotifications() const { showErrorNotification } = useUiNotifications()
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback() const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
@ -33,13 +33,11 @@ export const ClearHistoryButton: React.FC = () => {
closeModal() closeModal()
}, [closeModal, safeRefreshHistoryState, showErrorNotification]) }, [closeModal, safeRefreshHistoryState, showErrorNotification])
const buttonTitle = useTranslatedText('landing.history.toolbar.clear')
return ( return (
<Fragment> <Fragment>
<Button <Button variant={'secondary'} title={buttonTitle} onClick={showModal} {...cypressId('history-clear-button')}>
variant={'secondary'}
title={t('landing.history.toolbar.clear') ?? undefined}
onClick={showModal}
{...cypressId('history-clear-button')}>
<UiIcon icon={IconTrash} /> <UiIcon icon={IconTrash} />
</Button> </Button>
<DeletionModal <DeletionModal

View file

@ -3,21 +3,21 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { downloadHistory } from '../../../redux/history/methods' import { downloadHistory } from '../../../redux/history/methods'
import { UiIcon } from '../../common/icons/ui-icon' import { UiIcon } from '../../common/icons/ui-icon'
import React from 'react' import React from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { Download as IconDownload } from 'react-bootstrap-icons' import { Download as IconDownload } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
/** /**
* Renders a button to export the history. * Renders a button to export the history.
*/ */
export const ExportHistoryButton: React.FC = () => { export const ExportHistoryButton: React.FC = () => {
const { t } = useTranslation() const buttonTitle = useTranslatedText('landing.history.toolbar.export')
return ( return (
<Button variant={'secondary'} title={t('landing.history.toolbar.export') ?? undefined} onClick={downloadHistory}> <Button variant={'secondary'} title={buttonTitle} onClick={downloadHistory}>
<UiIcon icon={IconDownload} /> <UiIcon icon={IconDownload} />
</Button> </Button>
) )

View file

@ -3,23 +3,22 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { UiIcon } from '../../common/icons/ui-icon' import { UiIcon } from '../../common/icons/ui-icon'
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state' import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
import React from 'react' import React from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons' import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
/** /**
* Fetches the current history from the server. * Fetches the current history from the server.
*/ */
export const HistoryRefreshButton: React.FC = () => { export const HistoryRefreshButton: React.FC = () => {
const { t } = useTranslation()
const refreshHistory = useSafeRefreshHistoryStateCallback() const refreshHistory = useSafeRefreshHistoryStateCallback()
const buttonTitle = useTranslatedText('landing.history.toolbar.refresh')
return ( return (
<Button variant={'secondary'} title={t('landing.history.toolbar.refresh') ?? undefined} onClick={refreshHistory}> <Button variant={'secondary'} title={buttonTitle} onClick={refreshHistory}>
<UiIcon icon={IconArrowRepeat} /> <UiIcon icon={IconArrowRepeat} />
</Button> </Button>
) )

View file

@ -5,6 +5,7 @@
*/ */
import { HistoryEntryOrigin } from '../../../api/history/types' import { HistoryEntryOrigin } from '../../../api/history/types'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { importHistoryEntries, setHistoryEntries } from '../../../redux/history/methods' import { importHistoryEntries, setHistoryEntries } from '../../../redux/history/methods'
import { UiIcon } from '../../common/icons/ui-icon' import { UiIcon } from '../../common/icons/ui-icon'
import { ShowIf } from '../../common/show-if/show-if' import { ShowIf } from '../../common/show-if/show-if'
@ -23,7 +24,6 @@ import { useSyncToolbarStateToUrlEffect } from './toolbar-context/use-sync-toolb
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { Button, Col } from 'react-bootstrap' import { Button, Col } from 'react-bootstrap'
import { CloudUpload as IconCloudUpload } from 'react-bootstrap-icons' import { CloudUpload as IconCloudUpload } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
export enum ViewStateEnum { export enum ViewStateEnum {
CARD, CARD,
@ -34,7 +34,6 @@ export enum ViewStateEnum {
* Renders the toolbar for the history page that contains controls for filtering and sorting. * Renders the toolbar for the history page that contains controls for filtering and sorting.
*/ */
export const HistoryToolbar: React.FC = () => { export const HistoryToolbar: React.FC = () => {
const { t } = useTranslation()
const historyEntries = useApplicationState((state) => state.history) const historyEntries = useApplicationState((state) => state.history)
const userExists = useApplicationState((state) => !!state.user) const userExists = useApplicationState((state) => !!state.user)
const { showErrorNotification } = useUiNotifications() const { showErrorNotification } = useUiNotifications()
@ -61,6 +60,8 @@ export const HistoryToolbar: React.FC = () => {
}) })
}, [userExists, historyEntries, showErrorNotification, safeRefreshHistoryState]) }, [userExists, historyEntries, showErrorNotification, safeRefreshHistoryState])
const uploadAllButtonTitle = useTranslatedText('landing.history.toolbar.uploadAll')
return ( return (
<Col className={'d-flex flex-row'}> <Col className={'d-flex flex-row'}>
<div className={'me-1 mb-1'}> <div className={'me-1 mb-1'}>
@ -89,10 +90,7 @@ export const HistoryToolbar: React.FC = () => {
</div> </div>
<ShowIf condition={userExists}> <ShowIf condition={userExists}>
<div className={'me-1 mb-1'}> <div className={'me-1 mb-1'}>
<Button <Button variant={'secondary'} title={uploadAllButtonTitle} onClick={onUploadAllToRemote}>
variant={'secondary'}
title={t('landing.history.toolbar.uploadAll') ?? undefined}
onClick={onUploadAllToRemote}>
<UiIcon icon={IconCloudUpload} /> <UiIcon icon={IconCloudUpload} />
</Button> </Button>
</div> </div>

View file

@ -3,21 +3,19 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import { UiIcon } from '../../common/icons/ui-icon' import { UiIcon } from '../../common/icons/ui-icon'
import { ViewStateEnum } from './history-toolbar' import { ViewStateEnum } from './history-toolbar'
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state' import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { Button, ToggleButtonGroup } from 'react-bootstrap' import { Button, ToggleButtonGroup } from 'react-bootstrap'
import { StickyFill as IconStickyFill } from 'react-bootstrap-icons' import { StickyFill as IconStickyFill, Table as IconTable } from 'react-bootstrap-icons'
import { Table as IconTable } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
/** /**
* Toggles the view mode of the history entries between list and card view. * Toggles the view mode of the history entries between list and card view.
*/ */
export const HistoryViewModeToggleButton: React.FC = () => { export const HistoryViewModeToggleButton: React.FC = () => {
const { t } = useTranslation()
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState() const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
const onViewStateChange = useCallback( const onViewStateChange = useCallback(
@ -30,19 +28,25 @@ export const HistoryViewModeToggleButton: React.FC = () => {
[setHistoryToolbarState] [setHistoryToolbarState]
) )
const cardsButtonTitle = useTranslatedText('landing.history.toolbar.cards')
const tableButtonTitle = useTranslatedText('landing.history.toolbar.table')
const onCardsButtonClick = useCallback(() => onViewStateChange(ViewStateEnum.CARD), [onViewStateChange])
const onTableButtonClick = useCallback(() => onViewStateChange(ViewStateEnum.TABLE), [onViewStateChange])
return ( return (
<ToggleButtonGroup type='radio' name='options' dir='auto' className={'button-height'} onChange={onViewStateChange}> <ToggleButtonGroup type='radio' name='options' dir='auto' className={'button-height'} onChange={onViewStateChange}>
<Button <Button
title={t('landing.history.toolbar.cards') ?? undefined} title={cardsButtonTitle}
variant={historyToolbarState.viewState === ViewStateEnum.CARD ? 'secondary' : 'outline-secondary'} variant={historyToolbarState.viewState === ViewStateEnum.CARD ? 'secondary' : 'outline-secondary'}
onClick={() => onViewStateChange(ViewStateEnum.CARD)}> onClick={onCardsButtonClick}>
<UiIcon icon={IconStickyFill} className={'fa-fix-line-height'} /> <UiIcon icon={IconStickyFill} className={'fa-fix-line-height'} />
</Button> </Button>
<Button <Button
{...cypressId('history-mode-table')} {...cypressId('history-mode-table')}
variant={historyToolbarState.viewState === ViewStateEnum.TABLE ? 'secondary' : 'outline-secondary'} variant={historyToolbarState.viewState === ViewStateEnum.TABLE ? 'secondary' : 'outline-secondary'}
title={t('landing.history.toolbar.table') ?? undefined} title={tableButtonTitle}
onClick={() => onViewStateChange(ViewStateEnum.TABLE)}> onClick={onTableButtonClick}>
<UiIcon icon={IconTable} className={'fa-fix-line-height'} /> <UiIcon icon={IconTable} className={'fa-fix-line-height'} />
</Button> </Button>
</ToggleButtonGroup> </ToggleButtonGroup>

View file

@ -6,6 +6,7 @@
import type { HistoryEntryWithOrigin } from '../../../api/history/types' import type { HistoryEntryWithOrigin } from '../../../api/history/types'
import { HistoryEntryOrigin } from '../../../api/history/types' import { HistoryEntryOrigin } from '../../../api/history/types'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { convertV1History, importHistoryEntries, mergeHistoryEntries } from '../../../redux/history/methods' import { convertV1History, importHistoryEntries, mergeHistoryEntries } from '../../../redux/history/methods'
import type { HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types' import type { HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
@ -15,13 +16,11 @@ import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-his
import React, { useCallback, useRef, useState } from 'react' import React, { useCallback, useRef, useState } from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { Upload as IconUpload } from 'react-bootstrap-icons' import { Upload as IconUpload } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
/** /**
* Button that lets the user select a history JSON file and uploads imports that into the history. * Button that lets the user select a history JSON file and uploads imports that into the history.
*/ */
export const ImportHistoryButton: React.FC = () => { export const ImportHistoryButton: React.FC = () => {
const { t } = useTranslation()
const userExists = useApplicationState((state) => !!state.user) const userExists = useApplicationState((state) => !!state.user)
const historyState = useApplicationState((state) => state.history) const historyState = useApplicationState((state) => state.history)
const uploadInput = useRef<HTMLInputElement>(null) const uploadInput = useRef<HTMLInputElement>(null)
@ -117,6 +116,8 @@ export const ImportHistoryButton: React.FC = () => {
} }
} }
const buttonTitle = useTranslatedText('landing.history.toolbar.import')
return ( return (
<div> <div>
<input <input
@ -129,7 +130,7 @@ export const ImportHistoryButton: React.FC = () => {
/> />
<Button <Button
variant={'secondary'} variant={'secondary'}
title={t('landing.history.toolbar.import') ?? undefined} title={buttonTitle}
onClick={onUploadButtonClick} onClick={onUploadButtonClick}
{...cypressId('import-history-file-button')}> {...cypressId('import-history-file-button')}>
<UiIcon icon={IconUpload} /> <UiIcon icon={IconUpload} />

View file

@ -4,16 +4,15 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useOnInputChange } from '../../../hooks/common/use-on-input-change' import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state' import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
import React from 'react' import React from 'react'
import { FormControl } from 'react-bootstrap' import { FormControl } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
/** /**
* A text input that is used to filter history entries for specific keywords. * A text input that is used to filter history entries for specific keywords.
*/ */
export const KeywordSearchInput: React.FC = () => { export const KeywordSearchInput: React.FC = () => {
const { t } = useTranslation()
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState() const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
const onChange = useOnInputChange((search) => { const onChange = useOnInputChange((search) => {
@ -23,10 +22,12 @@ export const KeywordSearchInput: React.FC = () => {
})) }))
}) })
const searchKeywordsText = useTranslatedText('landing.history.toolbar.searchKeywords')
return ( return (
<FormControl <FormControl
placeholder={t('landing.history.toolbar.searchKeywords') ?? undefined} placeholder={searchKeywordsText}
aria-label={t('landing.history.toolbar.searchKeywords') ?? undefined} aria-label={searchKeywordsText}
onChange={onChange} onChange={onChange}
value={historyToolbarState.search} value={historyToolbarState.search}
/> />

View file

@ -4,17 +4,16 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state' import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { Typeahead } from 'react-bootstrap-typeahead' import { Typeahead } from 'react-bootstrap-typeahead'
import type { Option } from 'react-bootstrap-typeahead/types/types' import type { Option } from 'react-bootstrap-typeahead/types/types'
import { useTranslation } from 'react-i18next'
/** /**
* Renders an input field that filters history entries by selected tags. * Renders an input field that filters history entries by selected tags.
*/ */
export const TagSelectionInput: React.FC = () => { export const TagSelectionInput: React.FC = () => {
const { t } = useTranslation()
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState() const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
const historyEntries = useApplicationState((state) => state.history) const historyEntries = useApplicationState((state) => state.history)
@ -37,12 +36,13 @@ export const TagSelectionInput: React.FC = () => {
[setHistoryToolbarState] [setHistoryToolbarState]
) )
const placeholderText = useTranslatedText('landing.history.toolbar.selectTags')
return ( return (
<Typeahead <Typeahead
id={'tagsSelection'} id={'tagsSelection'}
options={tags} options={tags}
multiple={true} multiple={true}
placeholder={t('landing.history.toolbar.selectTags') ?? undefined} placeholder={placeholderText}
onChange={onChange} onChange={onChange}
selected={historyToolbarState.selectedTags} selected={historyToolbarState.selectedTags}
/> />

View file

@ -6,9 +6,7 @@
import { IconButton } from '../../common/icon-button/icon-button' import { IconButton } from '../../common/icon-button/icon-button'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import type { ButtonProps } from 'react-bootstrap' import type { ButtonProps } from 'react-bootstrap'
import { SortAlphaDown as IconSortAlphaDown } from 'react-bootstrap-icons' import { SortAlphaDown as IconSortAlphaDown, SortAlphaUp as IconSortAlphaUp, X as IconX } from 'react-bootstrap-icons'
import { SortAlphaUp as IconSortAlphaUp } from 'react-bootstrap-icons'
import { X as IconX } from 'react-bootstrap-icons'
export enum SortModeEnum { export enum SortModeEnum {
up = 1, up = 1,

View file

@ -8,9 +8,7 @@ import { IconDiscourse } from '../../common/icons/additional/icon-discourse'
import { IconMatrixOrg } from '../../common/icons/additional/icon-matrix-org' import { IconMatrixOrg } from '../../common/icons/additional/icon-matrix-org'
import { ExternalLink } from '../../common/links/external-link' import { ExternalLink } from '../../common/links/external-link'
import React from 'react' import React from 'react'
import { Github as IconGithub } from 'react-bootstrap-icons' import { Github as IconGithub, Globe as IconGlobe, Mastodon as IconMastodon } from 'react-bootstrap-icons'
import { Globe as IconGlobe } from 'react-bootstrap-icons'
import { Mastodon as IconMastodon } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
/** /**

View file

@ -3,6 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config' import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config'
import { ShowIf } from '../../common/show-if/show-if' import { ShowIf } from '../../common/show-if/show-if'
@ -12,7 +13,7 @@ import Link from 'next/link'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import type { ButtonProps } from 'react-bootstrap/Button' import type { ButtonProps } from 'react-bootstrap/Button'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
export type SignInButtonProps = Omit<ButtonProps, 'href'> export type SignInButtonProps = Omit<ButtonProps, 'href'>
@ -24,7 +25,6 @@ export type SignInButtonProps = Omit<ButtonProps, 'href'>
* @param props Further props inferred from the common button component. * @param props Further props inferred from the common button component.
*/ */
export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props }) => { export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props }) => {
const { t } = useTranslation()
const authProviders = useFrontendConfig().authProviders const authProviders = useFrontendConfig().authProviders
const loginLink = useMemo(() => { const loginLink = useMemo(() => {
@ -35,15 +35,12 @@ export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props })
} }
return '/login' return '/login'
}, [authProviders]) }, [authProviders])
const buttonTitle = useTranslatedText('login.signIn')
return ( return (
<ShowIf condition={authProviders.length > 0}> <ShowIf condition={authProviders.length > 0}>
<Link href={loginLink} passHref={true}> <Link href={loginLink} passHref={true}>
<Button <Button title={buttonTitle} {...cypressId('sign-in-button')} variant={variant || 'success'} {...props}>
title={t('login.signIn') ?? undefined}
{...cypressId('sign-in-button')}
variant={variant || 'success'}
{...props}>
<Trans i18nKey='login.signIn' /> <Trans i18nKey='login.signIn' />
</Button> </Button>
</Link> </Link>

View file

@ -3,6 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import type { CommonModalProps } from '../../common/modals/common-modal' import type { CommonModalProps } from '../../common/modals/common-modal'
import { CommonModal } from '../../common/modals/common-modal' import { CommonModal } from '../../common/modals/common-modal'
import { EditorSettingsTabContent } from './editor/editor-settings-tab-content' import { EditorSettingsTabContent } from './editor/editor-settings-tab-content'
@ -10,7 +11,6 @@ import { GlobalSettingsTabContent } from './global/global-settings-tab-content'
import React from 'react' import React from 'react'
import { Modal, Tab, Tabs } from 'react-bootstrap' import { Modal, Tab, Tabs } from 'react-bootstrap'
import { Gear as IconGear } from 'react-bootstrap-icons' import { Gear as IconGear } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
/** /**
* Shows global and scope specific settings * Shows global and scope specific settings
@ -19,7 +19,8 @@ import { useTranslation } from 'react-i18next'
* @param onHide callback that is executed if the modal should be closed * @param onHide callback that is executed if the modal should be closed
*/ */
export const SettingsModal: React.FC<CommonModalProps> = ({ show, onHide }) => { export const SettingsModal: React.FC<CommonModalProps> = ({ show, onHide }) => {
const { t } = useTranslation() const globalLabelTitle = useTranslatedText('settings.global.label')
const editorLabelTitle = useTranslatedText('settings.editor.label')
return ( return (
<CommonModal <CommonModal
@ -31,10 +32,10 @@ export const SettingsModal: React.FC<CommonModalProps> = ({ show, onHide }) => {
showCloseButton={true}> showCloseButton={true}>
<Modal.Body> <Modal.Body>
<Tabs navbar={false} variant={'tabs'} defaultActiveKey={'global'}> <Tabs navbar={false} variant={'tabs'} defaultActiveKey={'global'}>
<Tab title={t('settings.global.label')} eventKey={'global'}> <Tab title={globalLabelTitle} eventKey={'global'}>
<GlobalSettingsTabContent /> <GlobalSettingsTabContent />
</Tab> </Tab>
<Tab title={t('settings.editor.label')} eventKey={'editor'}> <Tab title={editorLabelTitle} eventKey={'editor'}>
<EditorSettingsTabContent /> <EditorSettingsTabContent />
</Tab> </Tab>
</Tabs> </Tabs>

View file

@ -3,11 +3,12 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../../hooks/common/use-translated-text'
import type { PropsWithDataTestId } from '../../../../utils/test-id' import type { PropsWithDataTestId } from '../../../../utils/test-id'
import React, { useCallback, useMemo } from 'react' import React, { useCallback } from 'react'
import type { ButtonProps } from 'react-bootstrap' import type { ButtonProps } from 'react-bootstrap'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
type DarkModeToggleButtonProps = Omit<ButtonProps, 'onSelect'> & type DarkModeToggleButtonProps = Omit<ButtonProps, 'onSelect'> &
PropsWithDataTestId & { PropsWithDataTestId & {
@ -36,9 +37,7 @@ export const SettingsToggleButton = ({
value, value,
...props ...props
}: DarkModeToggleButtonProps) => { }: DarkModeToggleButtonProps) => {
const { t } = useTranslation() const title = useTranslatedText(i18nKeyTooltip)
const title = useMemo(() => t(i18nKeyTooltip), [i18nKeyTooltip, t])
const onChange = useCallback(() => { const onChange = useCallback(() => {
if (!selected) { if (!selected) {

View file

@ -3,10 +3,10 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../../hooks/common/use-translated-text'
import type { AuthFieldProps } from './fields' import type { AuthFieldProps } from './fields'
import React from 'react' import React from 'react'
import { Form } from 'react-bootstrap' import { Form } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
/** /**
* Renders an input field for the password of a user. * Renders an input field for the password of a user.
@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
* @param invalid True when the entered password is invalid, false otherwise. * @param invalid True when the entered password is invalid, false otherwise.
*/ */
export const PasswordField: React.FC<AuthFieldProps> = ({ onChange, invalid }) => { export const PasswordField: React.FC<AuthFieldProps> = ({ onChange, invalid }) => {
const { t } = useTranslation() const placeholderText = useTranslatedText('login.auth.password')
return ( return (
<Form.Group> <Form.Group>
@ -23,7 +23,7 @@ export const PasswordField: React.FC<AuthFieldProps> = ({ onChange, invalid }) =
isInvalid={invalid} isInvalid={invalid}
type='password' type='password'
size='sm' size='sm'
placeholder={t('login.auth.password') ?? undefined} placeholder={placeholderText}
onChange={onChange} onChange={onChange}
autoComplete='current-password' autoComplete='current-password'
/> />

View file

@ -9,14 +9,16 @@ import { Logger } from '../../../../utils/logger'
import { IconGitlab } from '../../../common/icons/additional/icon-gitlab' import { IconGitlab } from '../../../common/icons/additional/icon-gitlab'
import styles from '../via-one-click.module.scss' import styles from '../via-one-click.module.scss'
import type { Icon } from 'react-bootstrap-icons' import type { Icon } from 'react-bootstrap-icons'
import { Dropbox as IconDropbox } from 'react-bootstrap-icons' import {
import { Exclamation as IconExclamation } from 'react-bootstrap-icons' Dropbox as IconDropbox,
import { Facebook as IconFacebook } from 'react-bootstrap-icons' Exclamation as IconExclamation,
import { Github as IconGithub } from 'react-bootstrap-icons' Facebook as IconFacebook,
import { Google as IconGoogle } from 'react-bootstrap-icons' Github as IconGithub,
import { People as IconPeople } from 'react-bootstrap-icons' Google as IconGoogle,
import { PersonRolodex as IconPersonRolodex } from 'react-bootstrap-icons' People as IconPeople,
import { Twitter as IconTwitter } from 'react-bootstrap-icons' PersonRolodex as IconPersonRolodex,
Twitter as IconTwitter
} from 'react-bootstrap-icons'
export interface OneClickMetadata { export interface OneClickMetadata {
name: string name: string

View file

@ -14,7 +14,7 @@ import { PasswordField } from './fields/password-field'
import { fetchAndSetUser } from './utils' import { fetchAndSetUser } from './utils'
import Link from 'next/link' import Link from 'next/link'
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import React, { useCallback, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Alert, Button, Card, Form } from 'react-bootstrap' import { Alert, Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
@ -48,11 +48,13 @@ export const ViaLocal: React.FC = () => {
const onUsernameChange = useLowercaseOnInputChange(setUsername) const onUsernameChange = useLowercaseOnInputChange(setUsername)
const onPasswordChange = useOnInputChange(setPassword) const onPasswordChange = useOnInputChange(setPassword)
const translationOptions = useMemo(() => ({ service: t('login.auth.username') }), [t])
return ( return (
<Card className='mb-4'> <Card className='mb-4'>
<Card.Body> <Card.Body>
<Card.Title> <Card.Title>
<Trans i18nKey='login.signInVia' values={{ service: t('login.auth.username') }} /> <Trans i18nKey='login.signInVia' values={translationOptions} />
</Card.Title> </Card.Title>
<Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}> <Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}>
<UsernameField onChange={onUsernameChange} isInvalid={!!error} value={username} /> <UsernameField onChange={onUsernameChange} isInvalid={!!error} value={username} />

View file

@ -3,11 +3,12 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useTranslatedText } from '../../../../hooks/common/use-translated-text'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Form } from 'react-bootstrap' import { Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
interface AccessTokenCreationFormLabelFieldProps extends AccessTokenCreationFormFieldProps { interface AccessTokenCreationFormLabelFieldProps extends AccessTokenCreationFormFieldProps {
onChangeLabel: (event: ChangeEvent<HTMLInputElement>) => void onChangeLabel: (event: ChangeEvent<HTMLInputElement>) => void
@ -23,11 +24,8 @@ export const AccessTokenCreationFormLabelField: React.FC<AccessTokenCreationForm
onChangeLabel, onChangeLabel,
formValues formValues
}) => { }) => {
const { t } = useTranslation() const labelValid = useMemo(() => formValues.label.trim() !== '', [formValues])
const placeholderText = useTranslatedText('profile.accessTokens.label')
const labelValid = useMemo(() => {
return formValues.label.trim() !== ''
}, [formValues])
return ( return (
<Form.Group> <Form.Group>
@ -37,7 +35,7 @@ export const AccessTokenCreationFormLabelField: React.FC<AccessTokenCreationForm
<Form.Control <Form.Control
type='text' type='text'
size='sm' size='sm'
placeholder={t('profile.accessTokens.label') ?? undefined} placeholder={placeholderText}
value={formValues.label} value={formValues.label}
onChange={onChangeLabel} onChange={onChangeLabel}
isValid={labelValid} isValid={labelValid}

View file

@ -8,8 +8,7 @@ import { UiIcon } from '../../common/icons/ui-icon'
import { AccountDeletionModal } from './account-deletion-modal' import { AccountDeletionModal } from './account-deletion-modal'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { Button, Card, Row } from 'react-bootstrap' import { Button, Card, Row } from 'react-bootstrap'
import { Trash as IconTrash } from 'react-bootstrap-icons' import { CloudDownload as IconCloudDownload, Trash as IconTrash } from 'react-bootstrap-icons'
import { CloudDownload as IconCloudDownload } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
/** /**

View file

@ -5,7 +5,7 @@
*/ */
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer' import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension' import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
import { screen, render } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import React from 'react' import React from 'react'
describe('blockquote extra tag', () => { describe('blockquote extra tag', () => {

View file

@ -8,8 +8,7 @@ import { codeFenceRegex } from '../../../components/editor-page/editor-pane/auto
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension' import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension' import { AppExtension } from '../../_base-classes/app-extension'
import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension' import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete' import type { CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete'
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
import { languages } from '@codemirror/language-data' import { languages } from '@codemirror/language-data'
/** /**

View file

@ -3,8 +3,8 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as HighlightedCodeModule from '../../../components/common/highlighted-code/highlighted-code'
import type { HighlightedCodeProps } from '../../../components/common/highlighted-code/highlighted-code' import type { HighlightedCodeProps } from '../../../components/common/highlighted-code/highlighted-code'
import * as HighlightedCodeModule from '../../../components/common/highlighted-code/highlighted-code'
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer' import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import { mockI18n } from '../../../test-utils/mock-i18n' import { mockI18n } from '../../../test-utils/mock-i18n'
import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension' import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension'

View file

@ -3,8 +3,8 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as ClickShieldModule from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import type { ClickShieldProps } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield' import type { ClickShieldProps } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import * as ClickShieldModule from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import { AsciinemaFrame } from './asciinema-frame' import { AsciinemaFrame } from './asciinema-frame'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import React from 'react' import React from 'react'

View file

@ -49,7 +49,7 @@ export const VegaLiteChart: React.FC<CodeProps> = ({ code }) => {
SVG_ACTION: t('renderer.vega-lite.svg') ?? undefined SVG_ACTION: t('renderer.vega-lite.svg') ?? undefined
} }
}) })
}, [code, vegaEmbed]) }, [code, vegaEmbed, t])
useEffect(() => { useEffect(() => {
if (renderingError) { if (renderingError) {

View file

@ -3,8 +3,8 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as ClickShieldModule from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import type { ClickShieldProps } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield' import type { ClickShieldProps } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import * as ClickShieldModule from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import { VimeoFrame } from './vimeo-frame' import { VimeoFrame } from './vimeo-frame'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import React from 'react' import React from 'react'

View file

@ -3,8 +3,8 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as ClickShieldModule from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import type { ClickShieldProps } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield' import type { ClickShieldProps } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import * as ClickShieldModule from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import { YouTubeFrame } from './youtube-frame' import { YouTubeFrame } from './youtube-frame'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import React from 'react' import React from 'react'

View file

@ -4,8 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from './use-application-state' import { useApplicationState } from './use-application-state'
import { useTranslatedText } from './use-translated-text'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
/** /**
* Retrieves the title of the note or a placeholder text, if no title is set. * Retrieves the title of the note or a placeholder text, if no title is set.
@ -13,8 +13,7 @@ import { useTranslation } from 'react-i18next'
* @return The title of the note * @return The title of the note
*/ */
export const useNoteTitle = (): string => { export const useNoteTitle = (): string => {
const { t } = useTranslation() const untitledNote = useTranslatedText('editor.untitledNote')
const untitledNote = useMemo(() => t('editor.untitledNote'), [t])
const noteTitle = useApplicationState((state) => state.noteDetails.title) const noteTitle = useApplicationState((state) => state.noteDetails.title)
return useMemo(() => (noteTitle === '' ? untitledNote : noteTitle), [noteTitle, untitledNote]) return useMemo(() => (noteTitle === '' ? untitledNote : noteTitle), [noteTitle, untitledNote])

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { TOptions } from 'i18next'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
/**
* Translates text and caches it.
*
* @param key The translation key
* @param tOptions Optional translation options
* @return the translated text
*/
export const useTranslatedText = (key: string, tOptions?: TOptions): string => {
const { t } = useTranslation()
return useMemo(() => (tOptions ? t(key, tOptions) : t(key)), [key, tOptions, t])
}

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useTranslatedText } from './use-translated-text'
import { renderHook } from '@testing-library/react'
import type { Namespace } from 'i18next'
import * as ReactI18NextModule from 'react-i18next'
import type { UseTranslationResponse } from 'react-i18next'
import { Mock } from 'ts-mockery'
jest.mock('react-i18next')
describe('useTranslatedText', () => {
const mockTranslation = 'mockTranslation'
const mockKey = 'mockKey'
let translateFunction: jest.Mock
beforeEach(() => {
translateFunction = jest.fn(() => mockTranslation)
const useTranslateMock = Mock.of<UseTranslationResponse<Namespace, unknown>>({
t: translateFunction
})
jest.spyOn(ReactI18NextModule, 'useTranslation').mockReturnValue(useTranslateMock)
})
it('translates text', () => {
const { result } = renderHook(() => useTranslatedText(mockKey))
expect(result.current).toBe(mockTranslation)
expect(translateFunction).toBeCalledWith(mockKey)
})
it('translates text with options', () => {
const mockOptions = {}
const { result } = renderHook(() => useTranslatedText(mockKey, mockOptions))
expect(result.current).toBe(mockTranslation)
expect(translateFunction).toBeCalledWith(mockKey, mockOptions)
})
})

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { store } from '..' import { store } from '..'
import type { SetRealtimeSyncStatusAction, SetRealtimeUsersAction, SetRealtimeConnectionStatusAction } from './types' import type { SetRealtimeConnectionStatusAction, SetRealtimeSyncStatusAction, SetRealtimeUsersAction } from './types'
import { RealtimeStatusActionType } from './types' import { RealtimeStatusActionType } from './types'
import type { RealtimeUser } from '@hedgedoc/commons' import type { RealtimeUser } from '@hedgedoc/commons'

View file

@ -3,7 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { RealtimeStatusActions, RealtimeStatus } from './types' import type { RealtimeStatus, RealtimeStatusActions } from './types'
import { RealtimeStatusActionType } from './types' import { RealtimeStatusActionType } from './types'
import type { Reducer } from 'redux' import type { Reducer } from 'redux'

View file

@ -4,8 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useBaseUrl } from '../hooks/common/use-base-url' import { useBaseUrl } from '../hooks/common/use-base-url'
import React, { Fragment, useMemo } from 'react'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React, { Fragment, useMemo } from 'react'
export interface ExpectedOriginBoundaryProps { export interface ExpectedOriginBoundaryProps {
currentOrigin?: string currentOrigin?: string