mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 19:26:31 -05:00
Add toasts (#1073)
Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
This commit is contained in:
parent
0b4a0afa16
commit
a86789dbef
11 changed files with 320 additions and 13 deletions
|
@ -32,6 +32,8 @@ import { Splitter } from './splitter/splitter'
|
|||
import { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
|
||||
import { RendererType } from '../render-page/rendering-message'
|
||||
import { useEditorModeFromUrl } from './hooks/useEditorModeFromUrl'
|
||||
import { UiNotifications } from '../notifications/ui-notifications'
|
||||
import { useNotificationTest } from './use-notification-test'
|
||||
|
||||
export interface EditorPagePathParams {
|
||||
id: string
|
||||
|
@ -82,8 +84,11 @@ export const EditorPage: React.FC = () => {
|
|||
scrollSource.current = ScrollSource.EDITOR
|
||||
}, [])
|
||||
|
||||
useNotificationTest()
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<UiNotifications/>
|
||||
<MotdBanner/>
|
||||
<div className={ 'd-flex flex-column vh-100' }>
|
||||
<AppBar mode={ AppBarMode.EDITOR }/>
|
||||
|
|
24
src/components/editor-page/use-notification-test.tsx
Normal file
24
src/components/editor-page/use-notification-test.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { DEFAULT_DURATION_IN_SECONDS, dispatchUiNotification } from '../../redux/ui-notifications/methods'
|
||||
|
||||
const localStorageKey = 'dontshowtestnotification'
|
||||
|
||||
export const useNotificationTest = (): void => {
|
||||
useEffect(() => {
|
||||
if (window.localStorage.getItem(localStorageKey)) {
|
||||
return
|
||||
}
|
||||
console.debug('[Notifications] Dispatched test notification')
|
||||
dispatchUiNotification('Notification-Test!', 'It Works!', DEFAULT_DURATION_IN_SECONDS, 'info-circle', [{
|
||||
label: 'Don\'t show again', onClick: () => {
|
||||
window.localStorage.setItem(localStorageKey, '1')
|
||||
}
|
||||
}])
|
||||
}, [])
|
||||
}
|
|
@ -4,26 +4,30 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import React, { Fragment } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { useDocumentTitle } from '../../hooks/common/use-document-title'
|
||||
import { MotdBanner } from '../common/motd-banner/motd-banner'
|
||||
import { Footer } from './footer/footer'
|
||||
import { HeaderBar } from './navigation/header-bar/header-bar'
|
||||
import { UiNotifications } from '../notifications/ui-notifications'
|
||||
|
||||
export const LandingLayout: React.FC = ({ children }) => {
|
||||
useDocumentTitle()
|
||||
|
||||
return (
|
||||
<Container className="text-light d-flex flex-column mvh-100">
|
||||
<MotdBanner/>
|
||||
<HeaderBar/>
|
||||
<div className={ 'd-flex flex-column justify-content-between flex-fill text-center' }>
|
||||
<main>
|
||||
{ children }
|
||||
</main>
|
||||
<Footer/>
|
||||
</div>
|
||||
</Container>
|
||||
<Fragment>
|
||||
<UiNotifications/>
|
||||
<Container className="text-light d-flex flex-column mvh-100">
|
||||
<MotdBanner/>
|
||||
<HeaderBar/>
|
||||
<div className={ 'd-flex flex-column justify-content-between flex-fill text-center' }>
|
||||
<main>
|
||||
{ children }
|
||||
</main>
|
||||
<Footer/>
|
||||
</div>
|
||||
</Container>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
|
22
src/components/notifications/notifications.scss
Normal file
22
src/components/notifications/notifications.scss
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.notifications-area {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
z-index: 2000;
|
||||
|
||||
.toast {
|
||||
min-width: 250px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.progress {
|
||||
border-radius: 0;
|
||||
height: 0.25rem;
|
||||
}
|
||||
}
|
92
src/components/notifications/ui-notification-toast.tsx
Normal file
92
src/components/notifications/ui-notification-toast.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, ProgressBar, Toast } from 'react-bootstrap'
|
||||
import { UiNotification } from '../../redux/ui-notifications/types'
|
||||
import { ForkAwesomeIcon } from '../common/fork-awesome/fork-awesome-icon'
|
||||
import { ShowIf } from '../common/show-if/show-if'
|
||||
import { IconName } from '../common/fork-awesome/types'
|
||||
import { dismissUiNotification } from '../../redux/ui-notifications/methods'
|
||||
|
||||
const STEPS_PER_SECOND = 10
|
||||
|
||||
export interface UiNotificationProps extends UiNotification {
|
||||
notificationId: number
|
||||
}
|
||||
|
||||
export const UiNotificationToast: React.FC<UiNotificationProps> = ({ title, content, date, icon, dismissed, notificationId, durationInSecond, buttons }) => {
|
||||
const [eta, setEta] = useState<number>()
|
||||
const interval = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
|
||||
const deleteInterval = useCallback(() => {
|
||||
if (interval.current) {
|
||||
clearInterval(interval.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismissThisNotification = useCallback(() => {
|
||||
console.debug(`[Notifications] Dismissed notification ${ notificationId }`)
|
||||
dismissUiNotification(notificationId)
|
||||
}, [notificationId])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (dismissed || !!interval.current) {
|
||||
return
|
||||
}
|
||||
console.debug(`[Notifications] Show notification ${ notificationId }`)
|
||||
setEta(durationInSecond * STEPS_PER_SECOND)
|
||||
interval.current = setInterval(() => setEta((lastETA) => {
|
||||
if (lastETA === undefined) {
|
||||
return
|
||||
} else if (lastETA <= 0) {
|
||||
return 0
|
||||
} else {
|
||||
return lastETA - 1
|
||||
}
|
||||
}), 1000 / STEPS_PER_SECOND)
|
||||
return () => {
|
||||
deleteInterval()
|
||||
}
|
||||
}, [deleteInterval, dismissThisNotification, dismissed, durationInSecond, notificationId])
|
||||
|
||||
useEffect(() => {
|
||||
if (eta === 0) {
|
||||
dismissThisNotification()
|
||||
}
|
||||
}, [dismissThisNotification, eta])
|
||||
|
||||
const buttonsDom = useMemo(() => buttons?.map(button => {
|
||||
const buttonClick = () => {
|
||||
button.onClick()
|
||||
dismissThisNotification()
|
||||
}
|
||||
return <Button key={ button.label } size={ 'sm' } onClick={ buttonClick }
|
||||
variant={ 'link' }>{ button.label }</Button>
|
||||
}
|
||||
), [buttons, dismissThisNotification])
|
||||
|
||||
return (
|
||||
<Toast show={ !dismissed && eta !== undefined } onClose={ dismissThisNotification }>
|
||||
<Toast.Header>
|
||||
<strong className="mr-auto">
|
||||
<ShowIf condition={ !!icon }>
|
||||
<ForkAwesomeIcon icon={ icon as IconName } fixedWidth={ true } className={ 'mr-1' }/>
|
||||
</ShowIf>
|
||||
{ title }
|
||||
</strong>
|
||||
<small>{ date.toRelative({ style: 'short' }) }</small>
|
||||
</Toast.Header>
|
||||
<Toast.Body>{ content }</Toast.Body>
|
||||
<ProgressBar variant={ 'info' } now={ eta } max={ durationInSecond * STEPS_PER_SECOND } min={ 0 }/>
|
||||
<div>
|
||||
{
|
||||
buttonsDom
|
||||
}
|
||||
</div>
|
||||
</Toast>
|
||||
)
|
||||
}
|
34
src/components/notifications/ui-notifications.tsx
Normal file
34
src/components/notifications/ui-notifications.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { UiNotificationToast } from './ui-notification-toast'
|
||||
import './notifications.scss'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { ApplicationState } from '../../redux'
|
||||
import equal from 'fast-deep-equal'
|
||||
|
||||
export const UiNotifications: React.FC = () => {
|
||||
const notifications = useSelector((state: ApplicationState) => state.uiNotifications, equal)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ 'notifications-area' }
|
||||
aria-live="polite"
|
||||
aria-atomic="true">
|
||||
{
|
||||
notifications.map((notification, notificationIndex) =>
|
||||
<UiNotificationToast
|
||||
key={ notificationIndex }
|
||||
notificationId={ notificationIndex }
|
||||
{ ...notification }/>)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -19,6 +19,8 @@ import { NoteDetailsReducer } from './note-details/reducers'
|
|||
import { NoteDetails } from './note-details/types'
|
||||
import { UserReducer } from './user/reducers'
|
||||
import { MaybeUserState } from './user/types'
|
||||
import { UiNotificationState } from './ui-notifications/types'
|
||||
import { UiNotificationReducer } from './ui-notifications/reducers'
|
||||
|
||||
export interface ApplicationState {
|
||||
user: MaybeUserState;
|
||||
|
@ -28,6 +30,7 @@ export interface ApplicationState {
|
|||
editorConfig: EditorConfig;
|
||||
darkMode: DarkModeConfig;
|
||||
noteDetails: NoteDetails;
|
||||
uiNotifications: UiNotificationState;
|
||||
}
|
||||
|
||||
export const allReducers: Reducer<ApplicationState> = combineReducers<ApplicationState>({
|
||||
|
@ -37,7 +40,8 @@ export const allReducers: Reducer<ApplicationState> = combineReducers<Applicatio
|
|||
apiUrl: ApiUrlReducer,
|
||||
editorConfig: EditorConfigReducer,
|
||||
darkMode: DarkModeConfigReducer,
|
||||
noteDetails: NoteDetailsReducer
|
||||
noteDetails: NoteDetailsReducer,
|
||||
uiNotifications: UiNotificationReducer
|
||||
})
|
||||
|
||||
export const store = createStore(allReducers)
|
||||
|
|
39
src/redux/ui-notifications/methods.ts
Normal file
39
src/redux/ui-notifications/methods.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '../index'
|
||||
import {
|
||||
DismissUiNotificationAction,
|
||||
DispatchUiNotificationAction,
|
||||
UiNotificationActionType,
|
||||
UiNotificationButton
|
||||
} from './types'
|
||||
import { DateTime } from 'luxon'
|
||||
import { IconName } from '../../components/common/fork-awesome/types'
|
||||
|
||||
export const DEFAULT_DURATION_IN_SECONDS = 10
|
||||
|
||||
export const dispatchUiNotification = (title: string, content: string, durationInSecond = DEFAULT_DURATION_IN_SECONDS, icon?: IconName, buttons?: UiNotificationButton[]): void => {
|
||||
store.dispatch({
|
||||
type: UiNotificationActionType.DISPATCH_NOTIFICATION,
|
||||
notification: {
|
||||
title,
|
||||
content,
|
||||
date: DateTime.now(),
|
||||
dismissed: false,
|
||||
icon,
|
||||
durationInSecond,
|
||||
buttons: buttons
|
||||
}
|
||||
} as DispatchUiNotificationAction)
|
||||
}
|
||||
|
||||
export const dismissUiNotification = (notificationId: number): void => {
|
||||
store.dispatch({
|
||||
type: UiNotificationActionType.DISMISS_NOTIFICATION,
|
||||
notificationId
|
||||
} as DismissUiNotificationAction)
|
||||
}
|
36
src/redux/ui-notifications/reducers.ts
Normal file
36
src/redux/ui-notifications/reducers.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
|
||||
import { Reducer } from 'redux'
|
||||
import {
|
||||
DismissUiNotificationAction,
|
||||
DispatchUiNotificationAction,
|
||||
UiNotificationAction,
|
||||
UiNotificationActionType,
|
||||
UiNotificationState
|
||||
} from './types'
|
||||
|
||||
export const UiNotificationReducer: Reducer<UiNotificationState, UiNotificationAction> = (state: UiNotificationState = [], action: UiNotificationAction) => {
|
||||
switch (action.type) {
|
||||
case UiNotificationActionType.DISPATCH_NOTIFICATION:
|
||||
return state.concat((action as DispatchUiNotificationAction).notification)
|
||||
case UiNotificationActionType.DISMISS_NOTIFICATION:
|
||||
return dismissNotification(state, (action as DismissUiNotificationAction).notificationId)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const dismissNotification = (notificationState: UiNotificationState, notificationIndex: number): UiNotificationState => {
|
||||
const newArray = [...notificationState]
|
||||
const oldNotification = newArray[notificationIndex]
|
||||
newArray[notificationIndex] = {
|
||||
...oldNotification,
|
||||
dismissed: true
|
||||
}
|
||||
return newArray
|
||||
}
|
45
src/redux/ui-notifications/types.ts
Normal file
45
src/redux/ui-notifications/types.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Action } from 'redux'
|
||||
import { DateTime } from 'luxon'
|
||||
import { IconName } from '../../components/common/fork-awesome/types'
|
||||
|
||||
export enum UiNotificationActionType {
|
||||
DISPATCH_NOTIFICATION = 'notification/dispatch',
|
||||
DISMISS_NOTIFICATION = 'notification/dismiss'
|
||||
}
|
||||
|
||||
export interface UiNotificationButton {
|
||||
label: string,
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface UiNotification {
|
||||
title: string
|
||||
date: DateTime
|
||||
content: string
|
||||
dismissed: boolean
|
||||
icon?: IconName
|
||||
durationInSecond: number
|
||||
buttons?: UiNotificationButton[]
|
||||
}
|
||||
|
||||
export interface UiNotificationAction extends Action<UiNotificationActionType> {
|
||||
type: UiNotificationActionType
|
||||
}
|
||||
|
||||
export interface DispatchUiNotificationAction extends UiNotificationAction {
|
||||
type: UiNotificationActionType.DISPATCH_NOTIFICATION
|
||||
notification: UiNotification
|
||||
}
|
||||
|
||||
export interface DismissUiNotificationAction extends UiNotificationAction {
|
||||
type: UiNotificationActionType.DISMISS_NOTIFICATION
|
||||
notificationId: number
|
||||
}
|
||||
|
||||
export type UiNotificationState = UiNotification[]
|
|
@ -1,4 +1,4 @@
|
|||
/*
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
@ -12,3 +12,5 @@ $dark: #222222 !default;
|
|||
@import "../../node_modules/bootstrap/scss/mixins";
|
||||
@import "../../node_modules/bootstrap/scss/variables";
|
||||
|
||||
$toast-background-color: $white;
|
||||
|
||||
|
|
Loading…
Reference in a new issue