mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-06 01:21:39 -04:00
fix: Move content into to frontend directory
Doing this BEFORE the merge prevents a lot of merge conflicts. Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
4e18ce38f3
commit
762a0a850e
1051 changed files with 0 additions and 35 deletions
|
@ -0,0 +1,22 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.notifications-area {
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
z-index: 2000;
|
||||
|
||||
.toast {
|
||||
min-width: 250px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.progress {
|
||||
border-radius: 0;
|
||||
height: 0.25rem;
|
||||
}
|
||||
}
|
29
frontend/src/components/notifications/types.ts
Normal file
29
frontend/src/components/notifications/types.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { TOptions } from 'i18next'
|
||||
import type { IconName } from '../common/fork-awesome/types'
|
||||
|
||||
export interface UiNotificationButton {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface DispatchOptions {
|
||||
titleI18nOptions: TOptions
|
||||
contentI18nOptions: TOptions
|
||||
durationInSecond: number
|
||||
icon?: IconName
|
||||
buttons: UiNotificationButton[]
|
||||
}
|
||||
|
||||
export interface UiNotification extends DispatchOptions {
|
||||
titleI18nKey: string
|
||||
contentI18nKey: string
|
||||
createdAtTimestamp: number
|
||||
dismissed: boolean
|
||||
uuid: string
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||
import { UiNotifications } from './ui-notifications'
|
||||
import type { DispatchOptions, UiNotification } from './types'
|
||||
import { DateTime } from 'luxon'
|
||||
import type { TOptions } from 'i18next'
|
||||
import { t } from 'i18next'
|
||||
import { Logger } from '../../utils/logger'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
const log = new Logger('Notifications')
|
||||
|
||||
interface UiNotificationContext {
|
||||
dispatchUiNotification: (
|
||||
titleI18nKey: string,
|
||||
contentI18nKey: string,
|
||||
dispatchOptions: Partial<DispatchOptions>
|
||||
) => void
|
||||
|
||||
showErrorNotification: (messageI18nKey: string, messageI18nOptions?: TOptions) => (error: Error) => void
|
||||
|
||||
dismissNotification: (notificationUuid: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides utility functions to manipulate the notifications in the current context.
|
||||
*/
|
||||
export const useUiNotifications: () => UiNotificationContext = () => {
|
||||
const communicatorFromContext = useContext(uiNotificationContext)
|
||||
if (!communicatorFromContext) {
|
||||
throw new Error('No ui notifications')
|
||||
}
|
||||
return communicatorFromContext
|
||||
}
|
||||
|
||||
export const DEFAULT_DURATION_IN_SECONDS = 10
|
||||
const uiNotificationContext = createContext<UiNotificationContext | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* Provides a UI-notification context for the given children.
|
||||
*
|
||||
* @param children The children that receive the context
|
||||
*/
|
||||
export const UiNotificationBoundary: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const [uiNotifications, setUiNotifications] = useState<UiNotification[]>([])
|
||||
|
||||
const dispatchUiNotification = useCallback(
|
||||
(
|
||||
titleI18nKey: string,
|
||||
contentI18nKey: string,
|
||||
{ icon, durationInSecond, buttons, contentI18nOptions, titleI18nOptions }: Partial<DispatchOptions>
|
||||
) => {
|
||||
setUiNotifications((oldState) => [
|
||||
...oldState,
|
||||
{
|
||||
titleI18nKey,
|
||||
contentI18nKey,
|
||||
createdAtTimestamp: DateTime.now().toSeconds(),
|
||||
dismissed: false,
|
||||
titleI18nOptions: titleI18nOptions ?? {},
|
||||
contentI18nOptions: contentI18nOptions ?? {},
|
||||
durationInSecond: durationInSecond ?? DEFAULT_DURATION_IN_SECONDS,
|
||||
buttons: buttons ?? [],
|
||||
icon: icon,
|
||||
uuid: uuid()
|
||||
}
|
||||
])
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const showErrorNotification = useCallback(
|
||||
(messageI18nKey: string, messageI18nOptions?: TOptions) =>
|
||||
(error: Error): void => {
|
||||
log.error(t(messageI18nKey, messageI18nOptions), error)
|
||||
void dispatchUiNotification('common.errorOccurred', messageI18nKey, {
|
||||
contentI18nOptions: messageI18nOptions,
|
||||
icon: 'exclamation-triangle'
|
||||
})
|
||||
},
|
||||
[dispatchUiNotification]
|
||||
)
|
||||
|
||||
const dismissNotification = useCallback((notificationUuid: string): void => {
|
||||
setUiNotifications((old) => {
|
||||
const found = old.find((notification) => notification.uuid === notificationUuid)
|
||||
if (found === undefined) {
|
||||
return old
|
||||
}
|
||||
return old.filter((value) => value.uuid !== notificationUuid).concat({ ...found, dismissed: true })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const context = useMemo(() => {
|
||||
return {
|
||||
dispatchUiNotification: dispatchUiNotification,
|
||||
showErrorNotification: showErrorNotification,
|
||||
dismissNotification: dismissNotification
|
||||
}
|
||||
}, [dismissNotification, dispatchUiNotification, showErrorNotification])
|
||||
|
||||
return (
|
||||
<uiNotificationContext.Provider value={context}>
|
||||
<UiNotifications notifications={uiNotifications} />
|
||||
{children}
|
||||
</uiNotificationContext.Provider>
|
||||
)
|
||||
}
|
123
frontend/src/components/notifications/ui-notification-toast.tsx
Normal file
123
frontend/src/components/notifications/ui-notification-toast.tsx
Normal file
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, ProgressBar, Toast } from 'react-bootstrap'
|
||||
import { ForkAwesomeIcon } from '../common/fork-awesome/fork-awesome-icon'
|
||||
import { ShowIf } from '../common/show-if/show-if'
|
||||
import type { IconName } from '../common/fork-awesome/types'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Logger } from '../../utils/logger'
|
||||
import { cypressId } from '../../utils/cypress-attribute'
|
||||
import { useEffectOnce, useInterval } from 'react-use'
|
||||
import styles from './notifications.module.scss'
|
||||
import { DateTime } from 'luxon'
|
||||
import type { UiNotification } from './types'
|
||||
import { useUiNotifications } from './ui-notification-boundary'
|
||||
|
||||
const STEPS_PER_SECOND = 10
|
||||
const log = new Logger('UiNotificationToast')
|
||||
|
||||
export interface UiNotificationProps {
|
||||
notification: UiNotification
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single notification.
|
||||
*
|
||||
* @param notification The notification to render
|
||||
*/
|
||||
export const UiNotificationToast: React.FC<UiNotificationProps> = ({ notification }) => {
|
||||
const { t } = useTranslation()
|
||||
const [remainingSteps, setRemainingSteps] = useState<number>(() => notification.durationInSecond * STEPS_PER_SECOND)
|
||||
const { dismissNotification } = useUiNotifications()
|
||||
|
||||
const dismissNow = useCallback(() => {
|
||||
log.debug(`Dismiss notification ${notification.uuid} immediately`)
|
||||
setRemainingSteps(0)
|
||||
}, [notification.uuid])
|
||||
|
||||
useEffectOnce(() => {
|
||||
log.debug(`Show notification ${notification.uuid}`)
|
||||
})
|
||||
|
||||
const formatCreatedAtDate = useCallback(() => {
|
||||
return DateTime.fromSeconds(notification.createdAtTimestamp).toRelative({ style: 'short' })
|
||||
}, [notification])
|
||||
|
||||
const [formattedCreatedAtDate, setFormattedCreatedAtDate] = useState(() => formatCreatedAtDate())
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
setRemainingSteps((lastRemainingSteps) => lastRemainingSteps - 1)
|
||||
setFormattedCreatedAtDate(formatCreatedAtDate())
|
||||
},
|
||||
!notification.dismissed && remainingSteps > 0 ? 1000 / STEPS_PER_SECOND : null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingSteps <= 0 && !notification.dismissed) {
|
||||
log.debug(`Dismiss notification ${notification.uuid}`)
|
||||
dismissNotification(notification.uuid)
|
||||
}
|
||||
}, [remainingSteps, notification.dismissed, notification.uuid, dismissNotification])
|
||||
|
||||
const buttonsDom = useMemo(
|
||||
() =>
|
||||
notification.buttons?.map((button, buttonIndex) => {
|
||||
const buttonClick = () => {
|
||||
button.onClick()
|
||||
dismissNow()
|
||||
}
|
||||
return (
|
||||
<Button key={buttonIndex} size={'sm'} onClick={buttonClick} variant={'link'}>
|
||||
{button.label}
|
||||
</Button>
|
||||
)
|
||||
}),
|
||||
[dismissNow, notification.buttons]
|
||||
)
|
||||
|
||||
const contentDom = useMemo(() => {
|
||||
return t(notification.contentI18nKey, notification.contentI18nOptions)
|
||||
.split('\n')
|
||||
.map((value, lineNumber) => {
|
||||
return (
|
||||
<Fragment key={lineNumber}>
|
||||
{value}
|
||||
<br />
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
}, [notification.contentI18nKey, notification.contentI18nOptions, t])
|
||||
|
||||
return (
|
||||
<Toast
|
||||
className={styles.toast}
|
||||
show={!notification.dismissed}
|
||||
onClose={dismissNow}
|
||||
{...cypressId('notification-toast')}>
|
||||
<Toast.Header>
|
||||
<strong className='me-auto'>
|
||||
<ShowIf condition={!!notification.icon}>
|
||||
<ForkAwesomeIcon icon={notification.icon as IconName} fixedWidth={true} className={'me-1'} />
|
||||
</ShowIf>
|
||||
<Trans i18nKey={notification.titleI18nKey} tOptions={notification.titleI18nOptions} />
|
||||
</strong>
|
||||
<small>{formattedCreatedAtDate}</small>
|
||||
</Toast.Header>
|
||||
<Toast.Body>{contentDom}</Toast.Body>
|
||||
<ProgressBar
|
||||
variant={'info'}
|
||||
now={remainingSteps}
|
||||
max={notification.durationInSecond * STEPS_PER_SECOND}
|
||||
min={STEPS_PER_SECOND}
|
||||
className={styles.progress}
|
||||
/>
|
||||
<div>{buttonsDom}</div>
|
||||
</Toast>
|
||||
)
|
||||
}
|
33
frontend/src/components/notifications/ui-notifications.tsx
Normal file
33
frontend/src/components/notifications/ui-notifications.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import { UiNotificationToast } from './ui-notification-toast'
|
||||
import styles from './notifications.module.scss'
|
||||
import type { UiNotification } from './types'
|
||||
|
||||
export interface UiNotificationsProps {
|
||||
notifications: UiNotification[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders {@link UiNotification notifications} in the top right corner sorted by creation time..
|
||||
*
|
||||
* @param notifications The notification to render
|
||||
*/
|
||||
export const UiNotifications: React.FC<UiNotificationsProps> = ({ notifications }) => {
|
||||
const notificationElements = useMemo(() => {
|
||||
return notifications
|
||||
.sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp)
|
||||
.map((notification) => <UiNotificationToast key={notification.uuid} notification={notification} />)
|
||||
}, [notifications])
|
||||
|
||||
return (
|
||||
<div className={styles['notifications-area']} aria-live='polite' aria-atomic='true'>
|
||||
{notificationElements}
|
||||
</div>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue