mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-03 08:28:54 -04:00
refactor: remove history page
This needs to be done since the backend does not include code for the history page anymore. This will be replaced with the explore page in the near future anyway. Co-authored-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
c0ce00b3f9
commit
d67e44f540
75 changed files with 76 additions and 2727 deletions
|
@ -3,7 +3,6 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { refreshHistoryState } from '../../../redux/history/methods'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import { isDevMode, isTestMode } from '../../../utils/test-modes'
|
||||
import { loadDarkMode } from './load-dark-mode'
|
||||
|
@ -66,10 +65,6 @@ export const createSetUpTaskList = (): InitTask[] => {
|
|||
name: 'Fetch user information',
|
||||
task: fetchUserInformation
|
||||
},
|
||||
{
|
||||
name: 'Load history state',
|
||||
task: refreshHistoryState
|
||||
},
|
||||
{
|
||||
name: 'Load preferences',
|
||||
task: loadFromLocalStorageAsync
|
||||
|
|
|
@ -11,7 +11,6 @@ import { useComponentsFromAppExtensions } from './editor-pane/hooks/use-componen
|
|||
import { useNoteAndAppTitle } from './head-meta-properties/use-note-and-app-title'
|
||||
import { useScrollState } from './hooks/use-scroll-state'
|
||||
import { useSetScrollSource } from './hooks/use-set-scroll-source'
|
||||
import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry'
|
||||
import { RendererPane } from './renderer-pane/renderer-pane'
|
||||
import { Sidebar } from './sidebar/sidebar'
|
||||
import { Splitter } from './splitter/splitter'
|
||||
|
@ -32,7 +31,6 @@ export enum ScrollSource {
|
|||
export const EditorPageContent: React.FC = () => {
|
||||
useTranslation()
|
||||
usePrintKeyboardShortcut()
|
||||
useUpdateLocalHistoryEntry()
|
||||
|
||||
const scrollSource = useRef<ScrollSource>(ScrollSource.EDITOR)
|
||||
const [editorScrollState, onMarkdownRendererScroll] = useScrollState(scrollSource, ScrollSource.EDITOR)
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../../api/history/types'
|
||||
import { HistoryEntryOrigin } from '../../../api/history/types'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { getGlobalState } from '../../../redux'
|
||||
import { updateLocalHistoryEntry } from '../../../redux/history/methods'
|
||||
import equal from 'fast-deep-equal'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* An effect that uses information of the current note state to update a local {@link HistoryEntryWithOrigin history entry}.
|
||||
* The entry is updated when the title or tags of the note change.
|
||||
*/
|
||||
export const useUpdateLocalHistoryEntry = (): void => {
|
||||
const id = useApplicationState((state) => state.noteDetails?.id)
|
||||
const userExists = useApplicationState((state) => !!state.user)
|
||||
const currentNoteTitle = useApplicationState((state) => state.noteDetails?.title ?? '')
|
||||
const currentNoteTags = useApplicationState((state) => state.noteDetails?.frontmatter.tags ?? [])
|
||||
const currentNoteOwner = useApplicationState((state) => state.noteDetails?.permissions.owner)
|
||||
const lastNoteTitle = useRef('')
|
||||
const lastNoteTags = useRef<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (userExists || id === undefined) {
|
||||
return
|
||||
}
|
||||
if (currentNoteTitle === lastNoteTitle.current && equal(currentNoteTags, lastNoteTags.current)) {
|
||||
return
|
||||
}
|
||||
const history = getGlobalState().history
|
||||
const entry: HistoryEntryWithOrigin = history.find((entry) => entry.identifier === id) ?? {
|
||||
identifier: id,
|
||||
title: '',
|
||||
pinStatus: false,
|
||||
lastVisitedAt: '',
|
||||
tags: [],
|
||||
origin: HistoryEntryOrigin.LOCAL,
|
||||
owner: null
|
||||
}
|
||||
if (entry.origin === HistoryEntryOrigin.REMOTE) {
|
||||
return
|
||||
}
|
||||
const updatedEntry = { ...entry }
|
||||
updatedEntry.title = currentNoteTitle
|
||||
updatedEntry.tags = currentNoteTags
|
||||
updatedEntry.owner = currentNoteOwner
|
||||
updatedEntry.lastVisitedAt = new Date().toISOString()
|
||||
updateLocalHistoryEntry(id, updatedEntry)
|
||||
lastNoteTitle.current = currentNoteTitle
|
||||
lastNoteTags.current = currentNoteTags
|
||||
}, [id, userExists, currentNoteTitle, currentNoteTags, currentNoteOwner])
|
||||
}
|
|
@ -24,24 +24,24 @@ const validAliasRegex = /^[a-z0-9_-]*$/
|
|||
*/
|
||||
export const AliasesAddForm: React.FC = () => {
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const isOwner = useIsOwner()
|
||||
const [newAlias, setNewAlias] = useState('')
|
||||
|
||||
const onAddAlias = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (noteId === undefined) {
|
||||
if (noteAlias === undefined) {
|
||||
return
|
||||
}
|
||||
addAlias(noteId, newAlias)
|
||||
addAlias(noteAlias, newAlias)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification('editor.modal.aliases.errorAddingAlias'))
|
||||
.finally(() => {
|
||||
setNewAlias('')
|
||||
})
|
||||
},
|
||||
[noteId, newAlias, setNewAlias, showErrorNotification]
|
||||
[noteAlias, newAlias, setNewAlias, showErrorNotification]
|
||||
)
|
||||
|
||||
const onNewAliasInputChange = useOnInputChange(setNewAlias)
|
||||
|
|
|
@ -15,10 +15,10 @@ import { Badge } from 'react-bootstrap'
|
|||
import { Button } from 'react-bootstrap'
|
||||
import { Star as IconStar, X as IconX } from 'react-bootstrap-icons'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import type { AliasDto } from '@hedgedoc/commons'
|
||||
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
|
||||
|
||||
export interface AliasesListEntryProps {
|
||||
alias: AliasDto
|
||||
alias: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -29,16 +29,17 @@ export interface AliasesListEntryProps {
|
|||
export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) => {
|
||||
const { t } = useTranslation()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const primaryAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const isOwner = useIsOwner()
|
||||
|
||||
const onRemoveClick = useCallback(() => {
|
||||
deleteAlias(alias.name)
|
||||
deleteAlias(alias)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification(t('editor.modal.aliases.errorRemovingAlias')))
|
||||
}, [alias, t, showErrorNotification])
|
||||
|
||||
const onMakePrimaryClick = useCallback(() => {
|
||||
markAliasAsPrimary(alias.name)
|
||||
markAliasAsPrimary(alias)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification(t('editor.modal.aliases.errorMakingPrimary')))
|
||||
}, [alias, t, showErrorNotification])
|
||||
|
@ -50,15 +51,15 @@ export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) =>
|
|||
return (
|
||||
<li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}>
|
||||
<div>
|
||||
{alias.name}
|
||||
{alias.primaryAlias && (
|
||||
{alias}
|
||||
{alias === primaryAlias && (
|
||||
<Badge bg='secondary' className={'ms-2'} title={isPrimaryText} {...testId('aliasPrimaryBadge')}>
|
||||
<Trans i18nKey={'editor.modal.aliases.primaryLabel'}></Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{!alias.primaryAlias && (
|
||||
{alias !== primaryAlias && (
|
||||
<Button
|
||||
className={'me-2'}
|
||||
variant='secondary'
|
||||
|
|
|
@ -17,31 +17,17 @@ jest.mock('./aliases-list-entry')
|
|||
describe('AliasesList', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
const primaryAlias = 'a-test'
|
||||
mockAppState({
|
||||
noteDetails: {
|
||||
aliases: [
|
||||
{
|
||||
name: 'a-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: false
|
||||
},
|
||||
{
|
||||
name: 'z-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: false
|
||||
},
|
||||
{
|
||||
name: 'b-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: true
|
||||
}
|
||||
]
|
||||
aliases: ['a-test', 'b-test', 'z-test'],
|
||||
primaryAlias: primaryAlias
|
||||
}
|
||||
})
|
||||
jest.spyOn(AliasesListEntryModule, 'AliasesListEntry').mockImplementation((({ alias }) => {
|
||||
return (
|
||||
<span>
|
||||
Alias: {alias.name} ({alias.primaryAlias ? 'primary' : 'non-primary'})
|
||||
Alias: {alias} ({alias === primaryAlias ? 'primary' : 'non-primary'})
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<AliasesListEntryProps>)
|
||||
|
|
|
@ -7,7 +7,6 @@ import { useApplicationState } from '../../../../../../hooks/common/use-applicat
|
|||
import type { ApplicationState } from '../../../../../../redux'
|
||||
import { AliasesListEntry } from './aliases-list-entry'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import type { AliasDto } from '@hedgedoc/commons'
|
||||
|
||||
/**
|
||||
* Renders the list of aliases.
|
||||
|
@ -18,8 +17,8 @@ export const AliasesList: React.FC = () => {
|
|||
return aliases === undefined
|
||||
? null
|
||||
: Object.assign([], aliases)
|
||||
.sort((a: AliasDto, b: AliasDto) => a.name.localeCompare(b.name))
|
||||
.map((alias: AliasDto) => <AliasesListEntry alias={alias} key={alias.name} />)
|
||||
.sort((a: string, b: string) => a.localeCompare(b))
|
||||
.map((alias: string) => <AliasesListEntry alias={alias} key={alias} />)
|
||||
}, [aliases])
|
||||
|
||||
return <Fragment>{aliasesDom}</Fragment>
|
||||
|
|
|
@ -28,21 +28,21 @@ export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarE
|
|||
useTranslation()
|
||||
const userIsOwner = useIsOwner()
|
||||
const router = useRouter()
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const [modalVisibility, showModal, closeModal] = useBooleanState()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const deleteNoteAndCloseDialog = useCallback(
|
||||
(keepMedia: boolean) => {
|
||||
if (noteId === undefined) {
|
||||
if (noteAlias === undefined) {
|
||||
return
|
||||
}
|
||||
deleteNote(noteId, keepMedia)
|
||||
deleteNote(noteAlias, keepMedia)
|
||||
.then(() => router.push('/history'))
|
||||
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
|
||||
.finally(closeModal)
|
||||
},
|
||||
[closeModal, noteId, router, showErrorNotification]
|
||||
[closeModal, noteAlias, router, showErrorNotification]
|
||||
)
|
||||
|
||||
if (!userIsOwner) {
|
||||
|
|
|
@ -38,42 +38,42 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
|
|||
disabled,
|
||||
inconsistent
|
||||
}) => {
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const { t } = useTranslation()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const onSetEntryReadOnly = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setGroupPermission(noteId, type, false)
|
||||
setGroupPermission(noteAlias, type, false)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, showErrorNotification, type])
|
||||
}, [noteAlias, showErrorNotification, type])
|
||||
|
||||
const onSetEntryWriteable = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setGroupPermission(noteId, type, true)
|
||||
setGroupPermission(noteAlias, type, true)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, showErrorNotification, type])
|
||||
}, [noteAlias, showErrorNotification, type])
|
||||
|
||||
const onSetEntryDenied = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
removeGroupPermission(noteId, type)
|
||||
removeGroupPermission(noteAlias, type)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, showErrorNotification, type])
|
||||
}, [noteAlias, showErrorNotification, type])
|
||||
|
||||
const name = useMemo(() => {
|
||||
switch (type) {
|
||||
|
|
|
@ -33,7 +33,7 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps & Permission
|
|||
entry,
|
||||
disabled
|
||||
}) => {
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const { [SpecialGroup.EVERYONE]: everyonePermission, [SpecialGroup.LOGGED_IN]: loggedInPermission } =
|
||||
useGetSpecialPermissions()
|
||||
|
@ -46,37 +46,37 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps & Permission
|
|||
)
|
||||
|
||||
const onRemoveEntry = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
removeUserPermission(noteId, entry.username)
|
||||
removeUserPermission(noteAlias, entry.username)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, entry.username, showErrorNotification])
|
||||
}, [noteAlias, entry.username, showErrorNotification])
|
||||
|
||||
const onSetEntryReadOnly = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setUserPermission(noteId, entry.username, false)
|
||||
setUserPermission(noteAlias, entry.username, false)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, entry.username, showErrorNotification])
|
||||
}, [noteAlias, entry.username, showErrorNotification])
|
||||
|
||||
const onSetEntryWriteable = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setUserPermission(noteId, entry.username, true)
|
||||
setUserPermission(noteAlias, entry.username, true)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, entry.username, showErrorNotification])
|
||||
}, [noteAlias, entry.username, showErrorNotification])
|
||||
|
||||
const { value, loading, error } = useAsync(async () => {
|
||||
return await getUserInfo(entry.username)
|
||||
|
|
|
@ -20,7 +20,7 @@ import { cypressId } from '../../../../../../utils/cypress-attribute'
|
|||
* @param disabled If the user is not the owner, functionality is disabled.
|
||||
*/
|
||||
export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ disabled }) => {
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const [changeOwner, setChangeOwner] = useState(false)
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
|
@ -30,10 +30,10 @@ export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ disa
|
|||
|
||||
const onOwnerChange = useCallback(
|
||||
(newOwner: string) => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setNoteOwner(noteId, newOwner)
|
||||
setNoteOwner(noteAlias, newOwner)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
|
@ -42,7 +42,7 @@ export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ disa
|
|||
setChangeOwner(false)
|
||||
})
|
||||
},
|
||||
[noteId, showErrorNotification]
|
||||
[noteAlias, showErrorNotification]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
@ -21,7 +21,7 @@ import { Trans, useTranslation } from 'react-i18next'
|
|||
export const PermissionSectionUsers: React.FC<PermissionDisabledProps> = ({ disabled }) => {
|
||||
useTranslation()
|
||||
const userPermissions = useApplicationState((state) => state.noteDetails?.permissions.sharedToUsers)
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const userEntries = useMemo(() => {
|
||||
|
@ -35,16 +35,16 @@ export const PermissionSectionUsers: React.FC<PermissionDisabledProps> = ({ disa
|
|||
|
||||
const onAddEntry = useCallback(
|
||||
(username: string) => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setUserPermission(noteId, username, false)
|
||||
setUserPermission(noteAlias, username, false)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
},
|
||||
[noteId, showErrorNotification]
|
||||
[noteAlias, showErrorNotification]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
@ -4,9 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { toggleHistoryEntryPinning } from '../../../../../redux/history/methods'
|
||||
import { concatCssClasses } from '../../../../../utils/concat-css-classes'
|
||||
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
||||
import { SidebarButton } from '../../sidebar-button/sidebar-button'
|
||||
import type { SpecificSidebarEntryProps } from '../../types'
|
||||
import styles from './pin-note-sidebar-entry.module.css'
|
||||
|
@ -24,27 +22,19 @@ import { WaitSpinner } from '../../../../common/wait-spinner/wait-spinner'
|
|||
export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ className, hide }) => {
|
||||
useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const history = useApplicationState((state) => state.history)
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
|
||||
const isPinned = useMemo(() => {
|
||||
const entry = history.find((entry) => entry.identifier === noteId)
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
return entry.pinStatus
|
||||
}, [history, noteId])
|
||||
// TODO Fix this when implementing the explore page
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const onPinClicked = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
toggleHistoryEntryPinning(noteId)
|
||||
.catch(showErrorNotification('landing.history.error.updateEntry.text'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [noteId, setLoading, showErrorNotification])
|
||||
}, [noteAlias, setLoading])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
|
@ -21,14 +21,14 @@ import { Trans } from 'react-i18next'
|
|||
*/
|
||||
export const RevisionDeleteModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
|
||||
const deleteAllRevisions = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
deleteRevisionsForNote(noteId).catch(showErrorNotification('editor.modal.deleteRevision.error')).finally(onHide)
|
||||
}, [noteId, onHide, showErrorNotification])
|
||||
deleteRevisionsForNote(noteAlias).catch(showErrorNotification('editor.modal.deleteRevision.error')).finally(onHide)
|
||||
}, [noteAlias, onHide, showErrorNotification])
|
||||
|
||||
return (
|
||||
<CommonModal
|
||||
|
|
|
@ -76,7 +76,7 @@ export const RevisionListEntry: React.FC<RevisionListEntryProps> = ({ active, on
|
|||
</span>
|
||||
<span>
|
||||
<UiIcon icon={IconPersonPlus} className='mx-2' />
|
||||
<Trans i18nKey={'editor.modal.revision.guestCount'} />: {revision.anonymousAuthorCount}
|
||||
<Trans i18nKey={'editor.modal.revision.guestCount'} />: {revision.authorGuestUuids.length}
|
||||
</span>
|
||||
</ListGroup.Item>
|
||||
)
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React, { Fragment } from 'react'
|
||||
import { Trash as IconTrash } from 'react-bootstrap-icons'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { DeleteNoteModal } from '../../editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal'
|
||||
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
|
||||
|
||||
export interface DeleteNoteItemProps {
|
||||
onConfirm: (keepMedia: boolean) => void
|
||||
noteTitle: string
|
||||
isOwner: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dropdown item for the {@link EntryMenu history entry menu} that allows to delete the note of the entry.
|
||||
*
|
||||
* @param noteTitle The title of the note to delete to show it in the deletion confirmation modal
|
||||
* @param onConfirm The callback that is fired when the deletion is confirmed
|
||||
*/
|
||||
export const DeleteNoteItem: React.FC<DeleteNoteItemProps> = ({ noteTitle, onConfirm, isOwner }) => {
|
||||
const [isModalVisible, showModal, hideModal] = useBooleanState()
|
||||
return (
|
||||
<Fragment>
|
||||
<Dropdown.Item onClick={showModal}>
|
||||
<UiIcon icon={IconTrash} className='mx-2' />
|
||||
<Trans i18nKey={'landing.history.menu.deleteNote'} />
|
||||
</Dropdown.Item>
|
||||
<DeleteNoteModal
|
||||
optionalNoteTitle={noteTitle}
|
||||
onConfirm={onConfirm}
|
||||
show={isModalVisible}
|
||||
onHide={hideModal}
|
||||
overrideIsOwner={isOwner}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.history-menu {
|
||||
&:global(.btn) {
|
||||
padding: 0.6rem 0.65rem;
|
||||
}
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { HistoryEntryOrigin } from '../../../api/history/types'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { DeleteNoteItem } from './delete-note-item'
|
||||
import styles from './entry-menu.module.scss'
|
||||
import { RemoveNoteEntryItem } from './remove-note-entry-item'
|
||||
import React from 'react'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { Cloud as IconCloud, Laptop as IconLaptop, ThreeDots as IconThreeDots } from 'react-bootstrap-icons'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
|
||||
export interface EntryMenuProps {
|
||||
id: string
|
||||
title: string
|
||||
origin: HistoryEntryOrigin
|
||||
noteOwner: string | null
|
||||
onRemoveFromHistory: () => void
|
||||
onDeleteNote: (keepMedia: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dropdown menu for a history entry containing options like removing the entry or deleting the note.
|
||||
*
|
||||
* @param id The unique identifier of the history entry.
|
||||
* @param title The title of the note of the history entry.
|
||||
* @param origin The origin of the entry. Must be either {@link HistoryEntryOrigin.LOCAL} or {@link HistoryEntryOrigin.REMOTE}.
|
||||
* @param noteOwner The username of the note owner.
|
||||
* @param onRemoveFromHistory Callback that is fired when the entry should be removed from the history.
|
||||
* @param onDeleteNote Callback that is fired when the note should be deleted.
|
||||
* @param className Additional CSS classes to add to the dropdown.
|
||||
*/
|
||||
export const EntryMenu: React.FC<EntryMenuProps> = ({
|
||||
id,
|
||||
title,
|
||||
origin,
|
||||
noteOwner,
|
||||
onRemoveFromHistory,
|
||||
onDeleteNote,
|
||||
className
|
||||
}) => {
|
||||
useTranslation()
|
||||
const userExists = useIsLoggedIn()
|
||||
const currentUsername = useApplicationState((state) => state.user?.username)
|
||||
|
||||
return (
|
||||
<Dropdown className={`d-inline-flex ${className || ''}`} {...cypressId('history-entry-menu')}>
|
||||
<Dropdown.Toggle
|
||||
variant={'secondary'}
|
||||
id={`dropdown-card-${id}`}
|
||||
className={`no-arrow ${styles['history-menu']} d-inline-flex align-items-center`}>
|
||||
<UiIcon icon={IconThreeDots} />
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Header>
|
||||
<Trans i18nKey='landing.history.menu.recentNotes' />
|
||||
</Dropdown.Header>
|
||||
|
||||
{origin === HistoryEntryOrigin.LOCAL && (
|
||||
<Dropdown.Item disabled>
|
||||
<UiIcon icon={IconLaptop} className='mx-2' />
|
||||
<Trans i18nKey='landing.history.menu.entryLocal' />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{origin === HistoryEntryOrigin.REMOTE && (
|
||||
<Dropdown.Item disabled>
|
||||
<UiIcon icon={IconCloud} className='mx-2' />
|
||||
<Trans i18nKey='landing.history.menu.entryRemote' />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
<RemoveNoteEntryItem onConfirm={onRemoveFromHistory} noteTitle={title} />
|
||||
|
||||
{userExists && currentUsername === noteOwner && (
|
||||
<>
|
||||
<Dropdown.Divider />
|
||||
<DeleteNoteItem onConfirm={onDeleteNote} noteTitle={title} isOwner={true} />
|
||||
</>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React, { Fragment } from 'react'
|
||||
import { Archive as IconArchive } from 'react-bootstrap-icons'
|
||||
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { DeletionModal } from '../../common/modals/deletion-modal'
|
||||
|
||||
export interface RemoveNoteEntryItemProps {
|
||||
onConfirm: () => void
|
||||
noteTitle: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a menu item for note deletion with a modal for confirmation.
|
||||
*
|
||||
* @param noteTitle The title of the note
|
||||
* @param onConfirm The callback to delete the note
|
||||
*/
|
||||
export const RemoveNoteEntryItem: React.FC<RemoveNoteEntryItemProps> = ({ noteTitle, onConfirm }) => {
|
||||
const [isModalVisible, showModal, hideModal] = useBooleanState()
|
||||
return (
|
||||
<Fragment>
|
||||
<Dropdown.Item onClick={showModal}>
|
||||
<UiIcon icon={IconArchive} className='mx-2' />
|
||||
<Trans i18nKey={'landing.history.menu.removeEntry'} />
|
||||
</Dropdown.Item>
|
||||
<DeletionModal
|
||||
deletionButtonI18nKey={'landing.history.modal.removeNote.button'}
|
||||
onConfirm={onConfirm}
|
||||
show={isModalVisible}
|
||||
onHide={hideModal}
|
||||
titleI18nKey={'landing.history.modal.removeNote.title'}>
|
||||
<h5>
|
||||
<Trans i18nKey={'landing.history.modal.removeNote.question'} />
|
||||
</h5>
|
||||
<ul>
|
||||
<li>{noteTitle}</li>
|
||||
</ul>
|
||||
<h6>
|
||||
<Trans i18nKey={'landing.history.modal.removeNote.warning'} />
|
||||
</h6>
|
||||
</DeletionModal>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Pager } from '../../common/pagination/pager'
|
||||
import type { HistoryEntriesProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||
import { HistoryCard } from './history-card'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Row } from 'react-bootstrap'
|
||||
|
||||
/**
|
||||
* Renders a paginated list of history entry cards.
|
||||
*
|
||||
* @param entries The history entries to render.
|
||||
* @param onPinClick Callback that is fired when the pinning button was clicked for an entry.
|
||||
* @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked for an entry.
|
||||
* @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked for an entry.
|
||||
* @param pageIndex The currently selected page.
|
||||
* @param onLastPageIndexChange Callback returning the last page index of the pager.
|
||||
*/
|
||||
export const HistoryCardList: React.FC<HistoryEntriesProps & HistoryEventHandlers> = ({
|
||||
entries,
|
||||
onPinClick,
|
||||
onRemoveEntryClick,
|
||||
onDeleteNoteClick,
|
||||
pageIndex,
|
||||
onLastPageIndexChange
|
||||
}) => {
|
||||
const entryCards = useMemo(() => {
|
||||
return entries.map((entry) => (
|
||||
<HistoryCard
|
||||
key={entry.identifier}
|
||||
entry={entry}
|
||||
onPinClick={onPinClick}
|
||||
onRemoveEntryClick={onRemoveEntryClick}
|
||||
onDeleteNoteClick={onDeleteNoteClick}
|
||||
/>
|
||||
))
|
||||
}, [entries, onPinClick, onRemoveEntryClick, onDeleteNoteClick])
|
||||
|
||||
return (
|
||||
<Row className='justify-content-start'>
|
||||
<Pager numberOfElementsPerPage={9} pageIndex={pageIndex} onLastPageIndexChange={onLastPageIndexChange}>
|
||||
{entryCards}
|
||||
</Pager>
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.card-min-height {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.card-footer-min-height {
|
||||
min-height: 27px;
|
||||
}
|
|
@ -1,106 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useDarkModeState } from '../../../hooks/dark-mode/use-dark-mode-state'
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { EntryMenu } from '../entry-menu/entry-menu'
|
||||
import type { HistoryEntryProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||
import { PinButton } from '../pin-button/pin-button'
|
||||
import { useHistoryEntryTitle } from '../use-history-entry-title'
|
||||
import { formatHistoryDate } from '../utils'
|
||||
import styles from './history-card.module.scss'
|
||||
import { DateTime } from 'luxon'
|
||||
import Link from 'next/link'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Badge, Card } from 'react-bootstrap'
|
||||
import { Clock as IconClock } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Renders a history entry as a card.
|
||||
*
|
||||
* @param entry The history entry.
|
||||
* @param onPinClick Callback that is fired when the pinning button was clicked.
|
||||
* @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked.
|
||||
* @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked.
|
||||
*/
|
||||
export const HistoryCard: React.FC<HistoryEntryProps & HistoryEventHandlers> = ({
|
||||
entry,
|
||||
onPinClick,
|
||||
onRemoveEntryClick,
|
||||
onDeleteNoteClick
|
||||
}) => {
|
||||
const onRemoveEntry = useCallback(() => {
|
||||
onRemoveEntryClick(entry.identifier)
|
||||
}, [onRemoveEntryClick, entry.identifier])
|
||||
|
||||
const onDeleteNote = useCallback(
|
||||
(keepMedia: boolean) => {
|
||||
onDeleteNoteClick(entry.identifier, keepMedia)
|
||||
},
|
||||
[onDeleteNoteClick, entry.identifier]
|
||||
)
|
||||
|
||||
const onPinEntry = useCallback(() => {
|
||||
onPinClick(entry.identifier)
|
||||
}, [onPinClick, entry.identifier])
|
||||
|
||||
const entryTitle = useHistoryEntryTitle(entry)
|
||||
|
||||
const darkModeState = useDarkModeState()
|
||||
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
entry.tags.map((tag) => {
|
||||
return (
|
||||
<Badge className={'bg-secondary text-light me-1 mb-1'} key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
)
|
||||
}),
|
||||
[entry.tags]
|
||||
)
|
||||
const lastVisited = useMemo(() => formatHistoryDate(entry.lastVisitedAt), [entry.lastVisitedAt])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='p-2 col-xs-12 col-sm-6 col-md-6 col-lg-4'
|
||||
{...cypressId('history-card')}
|
||||
{...cypressAttribute('card-title', entryTitle)}>
|
||||
<Card className={`${styles['card-min-height']}`} bg={darkModeState ? 'dark' : 'light'}>
|
||||
<Card.Body className='p-2 d-flex flex-row justify-content-between'>
|
||||
<div className={'d-flex flex-column'}>
|
||||
<PinButton isDark={false} isPinned={entry.pinStatus} onPinClick={onPinEntry} />
|
||||
</div>
|
||||
<Link href={`/n/${entry.identifier}`} className='text-decoration-none text-body-emphasis flex-fill'>
|
||||
<div className={'d-flex flex-column justify-content-between'}>
|
||||
<Card.Title className='m-0 mt-1dot5' {...cypressId('history-entry-title')}>
|
||||
{entryTitle}
|
||||
</Card.Title>
|
||||
<div>
|
||||
<div className='mt-2'>
|
||||
<UiIcon icon={IconClock} /> {DateTime.fromISO(entry.lastVisitedAt).toRelative()}
|
||||
<br />
|
||||
{lastVisited}
|
||||
</div>
|
||||
<div className={`${styles['card-footer-min-height']} p-0`}>{tags}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className={'d-flex flex-column'}>
|
||||
<EntryMenu
|
||||
id={entry.identifier}
|
||||
title={entryTitle}
|
||||
origin={entry.origin}
|
||||
onRemoveFromHistory={onRemoveEntry}
|
||||
onDeleteNote={onDeleteNote}
|
||||
noteOwner={entry.owner}
|
||||
/>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,130 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../../api/history/types'
|
||||
import { deleteNote } from '../../../api/notes'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { removeHistoryEntry, toggleHistoryEntryPinning } from '../../../redux/history/methods'
|
||||
import { PagerPagination } from '../../common/pagination/pager-pagination'
|
||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||
import { HistoryCardList } from '../history-card/history-card-list'
|
||||
import { HistoryTable } from '../history-table/history-table'
|
||||
import { ViewStateEnum } from '../history-toolbar/history-toolbar'
|
||||
import { useHistoryToolbarState } from '../history-toolbar/toolbar-context/use-history-toolbar-state'
|
||||
import { sortAndFilterEntries } from '../utils'
|
||||
import React, { Fragment, useCallback, useMemo, useState } from 'react'
|
||||
import { Alert, Col, Row } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
type OnEntryClick = (entryId: string) => void
|
||||
|
||||
export interface HistoryEventHandlers {
|
||||
onPinClick: OnEntryClick
|
||||
onRemoveEntryClick: OnEntryClick
|
||||
onDeleteNoteClick: (entryId: string, keepMedia: boolean) => void
|
||||
}
|
||||
|
||||
export interface HistoryEntryProps {
|
||||
entry: HistoryEntryWithOrigin
|
||||
}
|
||||
|
||||
export interface HistoryEntriesProps {
|
||||
entries: HistoryEntryWithOrigin[]
|
||||
pageIndex: number
|
||||
onLastPageIndexChange: (lastPageIndex: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content of the history based on the current history toolbar state.
|
||||
*/
|
||||
export const HistoryContent: React.FC = () => {
|
||||
useTranslation()
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
const [lastPageIndex, setLastPageIndex] = useState(0)
|
||||
|
||||
const allEntries = useApplicationState((state) => state.history)
|
||||
const [historyToolbarState] = useHistoryToolbarState()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const entriesToShow = useMemo<HistoryEntryWithOrigin[]>(
|
||||
() => sortAndFilterEntries(allEntries, historyToolbarState),
|
||||
[allEntries, historyToolbarState]
|
||||
)
|
||||
|
||||
const onPinClick = useCallback(
|
||||
(noteId: string) => {
|
||||
toggleHistoryEntryPinning(noteId).catch(showErrorNotification('landing.history.error.updateEntry.text'))
|
||||
},
|
||||
[showErrorNotification]
|
||||
)
|
||||
|
||||
const onDeleteClick = useCallback(
|
||||
(noteId: string, keepMedia: boolean) => {
|
||||
deleteNote(noteId, keepMedia)
|
||||
.then(() => removeHistoryEntry(noteId))
|
||||
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
|
||||
},
|
||||
[showErrorNotification]
|
||||
)
|
||||
|
||||
const onRemoveClick = useCallback(
|
||||
(noteId: string) => {
|
||||
removeHistoryEntry(noteId).catch(showErrorNotification('landing.history.error.deleteEntry.text'))
|
||||
},
|
||||
[showErrorNotification]
|
||||
)
|
||||
|
||||
const historyContent = useMemo(() => {
|
||||
switch (historyToolbarState.viewState) {
|
||||
case ViewStateEnum.TABLE:
|
||||
return (
|
||||
<HistoryTable
|
||||
entries={entriesToShow}
|
||||
onPinClick={onPinClick}
|
||||
onRemoveEntryClick={onRemoveClick}
|
||||
onDeleteNoteClick={onDeleteClick}
|
||||
pageIndex={pageIndex}
|
||||
onLastPageIndexChange={setLastPageIndex}
|
||||
/>
|
||||
)
|
||||
case ViewStateEnum.CARD:
|
||||
return (
|
||||
<HistoryCardList
|
||||
entries={entriesToShow}
|
||||
onPinClick={onPinClick}
|
||||
onRemoveEntryClick={onRemoveClick}
|
||||
onDeleteNoteClick={onDeleteClick}
|
||||
pageIndex={pageIndex}
|
||||
onLastPageIndexChange={setLastPageIndex}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}, [entriesToShow, historyToolbarState.viewState, onDeleteClick, onPinClick, onRemoveClick, pageIndex])
|
||||
|
||||
if (entriesToShow.length === 0) {
|
||||
return (
|
||||
<Row className={'justify-content-center'}>
|
||||
<Alert variant={'secondary'}>
|
||||
<Trans i18nKey={'landing.history.noHistory'} />
|
||||
</Alert>
|
||||
</Row>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Fragment>
|
||||
{historyContent}
|
||||
<Row>
|
||||
<Col className={'justify-content-center d-flex'}>
|
||||
<PagerPagination
|
||||
numberOfPageButtonsToShowAfterAndBeforeCurrent={2}
|
||||
lastPageIndex={lastPageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { EntryMenu } from '../entry-menu/entry-menu'
|
||||
import type { HistoryEntryProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||
import { PinButton } from '../pin-button/pin-button'
|
||||
import { useHistoryEntryTitle } from '../use-history-entry-title'
|
||||
import { formatHistoryDate } from '../utils'
|
||||
import Link from 'next/link'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
|
||||
/**
|
||||
* Renders a history entry as a table row.
|
||||
*
|
||||
* @param entry The history entry.
|
||||
* @param onPinClick Callback that is fired when the pinning button was clicked.
|
||||
* @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked.
|
||||
* @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked.
|
||||
*/
|
||||
export const HistoryTableRow: React.FC<HistoryEntryProps & HistoryEventHandlers> = ({
|
||||
entry,
|
||||
onPinClick,
|
||||
onRemoveEntryClick,
|
||||
onDeleteNoteClick
|
||||
}) => {
|
||||
const entryTitle = useHistoryEntryTitle(entry)
|
||||
|
||||
const onPinEntry = useCallback(() => {
|
||||
onPinClick(entry.identifier)
|
||||
}, [onPinClick, entry.identifier])
|
||||
|
||||
const onEntryRemove = useCallback(() => {
|
||||
onRemoveEntryClick(entry.identifier)
|
||||
}, [onRemoveEntryClick, entry.identifier])
|
||||
|
||||
const onDeleteNote = useCallback(
|
||||
(keepMedia: boolean) => {
|
||||
onDeleteNoteClick(entry.identifier, keepMedia)
|
||||
},
|
||||
[onDeleteNoteClick, entry.identifier]
|
||||
)
|
||||
|
||||
return (
|
||||
<tr {...cypressAttribute('entry-title', entryTitle)}>
|
||||
<td>
|
||||
<Link href={`/n/${entry.identifier}`} className='text-secondary' {...cypressId('history-entry-title')}>
|
||||
{entryTitle}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{formatHistoryDate(entry.lastVisitedAt)}</td>
|
||||
<td>
|
||||
{entry.tags.map((tag) => (
|
||||
<Badge className={'me-1 mb-1'} key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
<div className={'d-flex align-items-start justify-content-center'}>
|
||||
<PinButton isDark={true} isPinned={entry.pinStatus} onPinClick={onPinEntry} className={'mb-1 me-1'} />
|
||||
<EntryMenu
|
||||
id={entry.identifier}
|
||||
title={entryTitle}
|
||||
origin={entry.origin}
|
||||
noteOwner={entry.owner}
|
||||
onRemoveFromHistory={onEntryRemove}
|
||||
onDeleteNote={onDeleteNote}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.history-table tr {
|
||||
th, td {
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
width: 15%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useDarkModeState } from '../../../hooks/dark-mode/use-dark-mode-state'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { Pager } from '../../common/pagination/pager'
|
||||
import type { HistoryEntriesProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||
import { HistoryTableRow } from './history-table-row'
|
||||
import styles from './history-table.module.scss'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Table } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Renders a paginated table of history entries.
|
||||
*
|
||||
* @param entries The history entries to render.
|
||||
* @param onPinClick Callback that is fired when the pinning button was clicked for an entry.
|
||||
* @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked for an entry.
|
||||
* @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked for an entry.
|
||||
* @param pageIndex The currently selected page.
|
||||
* @param onLastPageIndexChange Callback returning the last page index of the pager.
|
||||
*/
|
||||
export const HistoryTable: React.FC<HistoryEntriesProps & HistoryEventHandlers> = ({
|
||||
entries,
|
||||
onPinClick,
|
||||
onRemoveEntryClick,
|
||||
onDeleteNoteClick,
|
||||
pageIndex,
|
||||
onLastPageIndexChange
|
||||
}) => {
|
||||
useTranslation()
|
||||
|
||||
const tableRows = useMemo(() => {
|
||||
return entries.map((entry) => (
|
||||
<HistoryTableRow
|
||||
key={entry.identifier}
|
||||
entry={entry}
|
||||
onPinClick={onPinClick}
|
||||
onRemoveEntryClick={onRemoveEntryClick}
|
||||
onDeleteNoteClick={onDeleteNoteClick}
|
||||
/>
|
||||
))
|
||||
}, [entries, onPinClick, onRemoveEntryClick, onDeleteNoteClick])
|
||||
|
||||
const darkModeState = useDarkModeState()
|
||||
|
||||
return (
|
||||
<Table
|
||||
striped
|
||||
bordered
|
||||
hover
|
||||
size='sm'
|
||||
variant={darkModeState ? 'dark' : 'light'}
|
||||
className={styles['history-table']}
|
||||
{...cypressId('history-table')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey={'landing.history.tableHeader.title'} />
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey={'landing.history.tableHeader.lastVisit'} />
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey={'landing.history.tableHeader.tags'} />
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey={'landing.history.tableHeader.actions'} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Pager numberOfElementsPerPage={12} pageIndex={pageIndex} onLastPageIndexChange={onLastPageIndexChange}>
|
||||
{tableRows}
|
||||
</Pager>
|
||||
</tbody>
|
||||
</Table>
|
||||
)
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { deleteAllHistoryEntries } from '../../../redux/history/methods'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { DeletionModal } from '../../common/modals/deletion-modal'
|
||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
|
||||
import React, { Fragment, useCallback } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Trash as IconTrash } from 'react-bootstrap-icons'
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Renders a button to clear the complete history of the user.
|
||||
* A confirmation modal will be presented to the user after clicking the button.
|
||||
*/
|
||||
export const ClearHistoryButton: React.FC = () => {
|
||||
const [modalVisibility, showModal, closeModal] = useBooleanState()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
deleteAllHistoryEntries().catch((error: Error) => {
|
||||
showErrorNotification('landing.history.error.deleteEntry.text')(error)
|
||||
safeRefreshHistoryState()
|
||||
})
|
||||
closeModal()
|
||||
}, [closeModal, safeRefreshHistoryState, showErrorNotification])
|
||||
|
||||
const buttonTitle = useTranslatedText('landing.history.toolbar.clear')
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button variant={'secondary'} title={buttonTitle} onClick={showModal} {...cypressId('history-clear-button')}>
|
||||
<UiIcon icon={IconTrash} />
|
||||
</Button>
|
||||
<DeletionModal
|
||||
onConfirm={onConfirm}
|
||||
deletionButtonI18nKey={'landing.history.toolbar.clear'}
|
||||
show={modalVisibility}
|
||||
onHide={closeModal}
|
||||
titleI18nKey={'landing.history.modal.clearHistory.title'}>
|
||||
<h5>
|
||||
<Trans i18nKey={'landing.history.modal.clearHistory.question'} />
|
||||
</h5>
|
||||
<h6>
|
||||
<Trans i18nKey={'landing.history.modal.clearHistory.disclaimer'} />
|
||||
</h6>
|
||||
</DeletionModal>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { downloadHistory } from '../../../redux/history/methods'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import React from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Download as IconDownload } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Renders a button to export the history.
|
||||
*/
|
||||
export const ExportHistoryButton: React.FC = () => {
|
||||
const buttonTitle = useTranslatedText('landing.history.toolbar.export')
|
||||
|
||||
return (
|
||||
<Button variant={'secondary'} title={buttonTitle} onClick={downloadHistory}>
|
||||
<UiIcon icon={IconDownload} />
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
|
||||
import React from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Fetches the current history from the server.
|
||||
*/
|
||||
export const HistoryRefreshButton: React.FC = () => {
|
||||
const refreshHistory = useSafeRefreshHistoryStateCallback()
|
||||
const buttonTitle = useTranslatedText('landing.history.toolbar.refresh')
|
||||
|
||||
return (
|
||||
<Button variant={'secondary'} title={buttonTitle} onClick={refreshHistory}>
|
||||
<UiIcon icon={IconArrowRepeat} />
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { SortModeEnum } from '../sort-button/sort-button'
|
||||
import type { ViewStateEnum } from './history-toolbar'
|
||||
|
||||
export type HistoryToolbarState = {
|
||||
viewState: ViewStateEnum
|
||||
search: string
|
||||
selectedTags: string[]
|
||||
titleSortDirection: SortModeEnum
|
||||
lastVisitedSortDirection: SortModeEnum
|
||||
}
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { HistoryEntryOrigin } from '../../../api/history/types'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { importHistoryEntries, setHistoryEntries } from '../../../redux/history/methods'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||
import { ClearHistoryButton } from './clear-history-button'
|
||||
import { ExportHistoryButton } from './export-history-button'
|
||||
import { HistoryRefreshButton } from './history-refresh-button'
|
||||
import { HistoryViewModeToggleButton } from './history-view-mode-toggle-button'
|
||||
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
|
||||
import { ImportHistoryButton } from './import-history-button'
|
||||
import { KeywordSearchInput } from './keyword-search-input'
|
||||
import { SortByLastVisitedButton } from './sort-by-last-visited-button'
|
||||
import { SortByTitleButton } from './sort-by-title-button'
|
||||
import { TagSelectionInput } from './tag-selection-input'
|
||||
import { useSyncToolbarStateToUrlEffect } from './toolbar-context/use-sync-toolbar-state-to-url-effect'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Button, Col } from 'react-bootstrap'
|
||||
import { CloudUpload as IconCloudUpload } from 'react-bootstrap-icons'
|
||||
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
|
||||
|
||||
export enum ViewStateEnum {
|
||||
CARD,
|
||||
TABLE
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the toolbar for the history page that contains controls for filtering and sorting.
|
||||
*/
|
||||
export const HistoryToolbar: React.FC = () => {
|
||||
const historyEntries = useApplicationState((state) => state.history)
|
||||
const userExists = useIsLoggedIn()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
|
||||
useSyncToolbarStateToUrlEffect()
|
||||
|
||||
const onUploadAllToRemote = useCallback(() => {
|
||||
if (!userExists) {
|
||||
return
|
||||
}
|
||||
const localEntries = historyEntries
|
||||
.filter((entry) => entry.origin === HistoryEntryOrigin.LOCAL)
|
||||
.map((entry) => entry.identifier)
|
||||
historyEntries.forEach((entry) => (entry.origin = HistoryEntryOrigin.REMOTE))
|
||||
importHistoryEntries(historyEntries).catch((error: Error) => {
|
||||
showErrorNotification('landing.history.error.setHistory.text')(error)
|
||||
historyEntries.forEach((entry) => {
|
||||
if (localEntries.includes(entry.identifier)) {
|
||||
entry.origin = HistoryEntryOrigin.LOCAL
|
||||
}
|
||||
})
|
||||
setHistoryEntries(historyEntries)
|
||||
safeRefreshHistoryState()
|
||||
})
|
||||
}, [userExists, historyEntries, showErrorNotification, safeRefreshHistoryState])
|
||||
|
||||
const uploadAllButtonTitle = useTranslatedText('landing.history.toolbar.uploadAll')
|
||||
|
||||
return (
|
||||
<Col className={'d-flex flex-row flex-wrap'}>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<TagSelectionInput />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<KeywordSearchInput />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<SortByTitleButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<SortByLastVisitedButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<ExportHistoryButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<ImportHistoryButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<ClearHistoryButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<HistoryRefreshButton />
|
||||
</div>
|
||||
{userExists && (
|
||||
<div className={'me-1 mb-1'}>
|
||||
<Button variant={'secondary'} title={uploadAllButtonTitle} onClick={onUploadAllToRemote}>
|
||||
<UiIcon icon={IconCloudUpload} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className={'me-1 mb-1'}>
|
||||
<HistoryViewModeToggleButton />
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { ViewStateEnum } from './history-toolbar'
|
||||
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Button, ToggleButtonGroup } from 'react-bootstrap'
|
||||
import { StickyFill as IconStickyFill, Table as IconTable } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Toggles the view mode of the history entries between list and card view.
|
||||
*/
|
||||
export const HistoryViewModeToggleButton: React.FC = () => {
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const onViewStateChange = useCallback(
|
||||
(newViewState: ViewStateEnum) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
viewState: newViewState
|
||||
}))
|
||||
},
|
||||
[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 (
|
||||
<ToggleButtonGroup type='radio' name='options' dir='auto' className={'button-height'} onChange={onViewStateChange}>
|
||||
<Button
|
||||
title={cardsButtonTitle}
|
||||
variant={historyToolbarState.viewState === ViewStateEnum.CARD ? 'secondary' : 'outline-secondary'}
|
||||
onClick={onCardsButtonClick}>
|
||||
<UiIcon icon={IconStickyFill} className={'fa-fix-line-height'} />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('history-mode-table')}
|
||||
variant={historyToolbarState.viewState === ViewStateEnum.TABLE ? 'secondary' : 'outline-secondary'}
|
||||
title={tableButtonTitle}
|
||||
onClick={onTableButtonClick}>
|
||||
<UiIcon icon={IconTable} className={'fa-fix-line-height'} />
|
||||
</Button>
|
||||
</ToggleButtonGroup>
|
||||
)
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { refreshHistoryState } from '../../../../redux/history/methods'
|
||||
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Tries to refresh the history from the backend and shows notification if that request fails.
|
||||
*/
|
||||
export const useSafeRefreshHistoryStateCallback = () => {
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
return useCallback(() => {
|
||||
refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text'))
|
||||
}, [showErrorNotification])
|
||||
}
|
|
@ -1,142 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../../api/history/types'
|
||||
import { HistoryEntryOrigin } from '../../../api/history/types'
|
||||
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 type { HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Upload as IconUpload } from 'react-bootstrap-icons'
|
||||
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
|
||||
|
||||
/**
|
||||
* Button that lets the user select a history JSON file and uploads imports that into the history.
|
||||
*/
|
||||
export const ImportHistoryButton: React.FC = () => {
|
||||
const userExists = useIsLoggedIn()
|
||||
const historyState = useApplicationState((state) => state.history)
|
||||
const uploadInput = useRef<HTMLInputElement>(null)
|
||||
const [fileName, setFilename] = useState('')
|
||||
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
|
||||
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
|
||||
|
||||
const onImportHistory = useCallback(
|
||||
(entries: HistoryEntryWithOrigin[]): void => {
|
||||
entries.forEach((entry) => (entry.origin = userExists ? HistoryEntryOrigin.REMOTE : HistoryEntryOrigin.LOCAL))
|
||||
importHistoryEntries(mergeHistoryEntries(historyState, entries)).catch((error: Error) => {
|
||||
showErrorNotification('landing.history.error.setHistory.text')(error)
|
||||
safeRefreshHistoryState()
|
||||
})
|
||||
},
|
||||
[historyState, safeRefreshHistoryState, showErrorNotification, userExists]
|
||||
)
|
||||
|
||||
const resetInputField = useCallback(() => {
|
||||
if (!uploadInput.current) {
|
||||
return
|
||||
}
|
||||
uploadInput.current.value = ''
|
||||
}, [uploadInput])
|
||||
|
||||
const onUploadButtonClick = useCallback(() => uploadInput.current?.click(), [uploadInput])
|
||||
|
||||
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const { validity, files } = event.target
|
||||
if (files && files[0] && validity.valid) {
|
||||
const file = files[0]
|
||||
setFilename(file.name)
|
||||
if (file.type !== 'application/json' && file.type !== '') {
|
||||
void dispatchUiNotification('common.errorOccurred', 'landing.history.modal.importHistoryError.textWithFile', {
|
||||
contentI18nOptions: {
|
||||
fileName
|
||||
}
|
||||
})
|
||||
resetInputField()
|
||||
return
|
||||
}
|
||||
//TODO: [mrdrogdrog] The following whole block can be shortened using our `readFile` util.
|
||||
// But I won't do it right now because the whole components needs a make over and that's definitely out of scope for my current PR.
|
||||
// https://github.com/hedgedoc/hedgedoc/issues/5042
|
||||
const fileReader = new FileReader()
|
||||
fileReader.onload = (event) => {
|
||||
if (event.target && event.target.result) {
|
||||
try {
|
||||
const result = event.target.result as string
|
||||
const data = JSON.parse(result) as HistoryExportJson
|
||||
if (data) {
|
||||
if (data.version) {
|
||||
if (data.version === 2) {
|
||||
onImportHistory(data.entries)
|
||||
} else {
|
||||
// probably a newer version we can't support
|
||||
void dispatchUiNotification(
|
||||
'common.errorOccurred',
|
||||
'landing.history.modal.importHistoryError.tooNewVersion',
|
||||
{
|
||||
contentI18nOptions: {
|
||||
fileName
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const oldEntries = JSON.parse(result) as V1HistoryEntry[]
|
||||
onImportHistory(convertV1History(oldEntries))
|
||||
}
|
||||
}
|
||||
resetInputField()
|
||||
} catch {
|
||||
void dispatchUiNotification(
|
||||
'common.errorOccurred',
|
||||
'landing.history.modal.importHistoryError.textWithFile',
|
||||
{
|
||||
contentI18nOptions: {
|
||||
fileName
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fileReader.readAsText(file)
|
||||
} else {
|
||||
void dispatchUiNotification(
|
||||
'common.errorOccurred',
|
||||
'landing.history.modal.importHistoryError.textWithOutFile',
|
||||
{}
|
||||
)
|
||||
resetInputField()
|
||||
}
|
||||
}
|
||||
|
||||
const buttonTitle = useTranslatedText('landing.history.toolbar.import')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type='file'
|
||||
className='d-none'
|
||||
accept='.json'
|
||||
onChange={handleUpload}
|
||||
ref={uploadInput}
|
||||
{...cypressId('import-history-file-input')}
|
||||
/>
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
title={buttonTitle}
|
||||
onClick={onUploadButtonClick}
|
||||
{...cypressId('import-history-file-button')}>
|
||||
<UiIcon icon={IconUpload} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
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 React from 'react'
|
||||
import { FormControl } from 'react-bootstrap'
|
||||
|
||||
/**
|
||||
* A text input that is used to filter history entries for specific keywords.
|
||||
*/
|
||||
export const KeywordSearchInput: React.FC = () => {
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const onChange = useOnInputChange((search) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
search
|
||||
}))
|
||||
})
|
||||
|
||||
const searchKeywordsText = useTranslatedText('landing.history.toolbar.searchKeywords')
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
placeholder={searchKeywordsText}
|
||||
aria-label={searchKeywordsText}
|
||||
onChange={onChange}
|
||||
value={historyToolbarState.search}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { SortButton, SortModeEnum } from '../sort-button/sort-button'
|
||||
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Controls if history entries should be sorted by the last visited date.
|
||||
*/
|
||||
export const SortByLastVisitedButton: React.FC = () => {
|
||||
useTranslation()
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const lastVisitedSortChanged = useCallback(
|
||||
(direction: SortModeEnum) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
lastVisitedSortDirection: direction,
|
||||
titleSortDirection: SortModeEnum.no
|
||||
}))
|
||||
},
|
||||
[setHistoryToolbarState]
|
||||
)
|
||||
|
||||
return (
|
||||
<SortButton onDirectionChange={lastVisitedSortChanged} direction={historyToolbarState.lastVisitedSortDirection}>
|
||||
<Trans i18nKey={'landing.history.toolbar.sortByLastVisited'} />
|
||||
</SortButton>
|
||||
)
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { SortButton, SortModeEnum } from '../sort-button/sort-button'
|
||||
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Controls if history entries should be sorted by title.
|
||||
*/
|
||||
export const SortByTitleButton: React.FC = () => {
|
||||
useTranslation()
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const titleSortChanged = useCallback(
|
||||
(direction: SortModeEnum) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
lastVisitedSortDirection: SortModeEnum.no,
|
||||
titleSortDirection: direction
|
||||
}))
|
||||
},
|
||||
[setHistoryToolbarState]
|
||||
)
|
||||
|
||||
return (
|
||||
<SortButton onDirectionChange={titleSortChanged} direction={historyToolbarState.titleSortDirection}>
|
||||
<Trans i18nKey={'landing.history.toolbar.sortByTitle'} />
|
||||
</SortButton>
|
||||
)
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
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 React, { useCallback, useMemo } from 'react'
|
||||
import { Typeahead } from 'react-bootstrap-typeahead'
|
||||
import type { Option } from 'react-bootstrap-typeahead/types/types'
|
||||
|
||||
/**
|
||||
* Renders an input field that filters history entries by selected tags.
|
||||
*/
|
||||
export const TagSelectionInput: React.FC = () => {
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const historyEntries = useApplicationState((state) => state.history)
|
||||
|
||||
const tags = useMemo<string[]>(() => {
|
||||
const allTags = historyEntries
|
||||
.map((entry) => entry.tags)
|
||||
.flat()
|
||||
.sort((first, second) => first.toLowerCase().localeCompare(second.toLowerCase()))
|
||||
return Array.from(new Set(allTags))
|
||||
}, [historyEntries])
|
||||
|
||||
const onChange = useCallback(
|
||||
(selectedTags: Option[]) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
selectedTags: selectedTags as string[]
|
||||
}))
|
||||
},
|
||||
[setHistoryToolbarState]
|
||||
)
|
||||
|
||||
const placeholderText = useTranslatedText('landing.history.toolbar.selectTags')
|
||||
return (
|
||||
<Typeahead
|
||||
id={'tagsSelection'}
|
||||
options={tags}
|
||||
multiple={true}
|
||||
placeholder={placeholderText}
|
||||
onChange={onChange}
|
||||
selected={historyToolbarState.selectedTags}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useArrayStringUrlParameter } from '../../../../hooks/common/use-array-string-url-parameter'
|
||||
import { useSingleStringUrlParameter } from '../../../../hooks/common/use-single-string-url-parameter'
|
||||
import { SortModeEnum } from '../../sort-button/sort-button'
|
||||
import { ViewStateEnum } from '../history-toolbar'
|
||||
import type { HistoryToolbarState } from '../history-toolbar-state'
|
||||
import type { HistoryToolbarStateWithDispatcher } from './toolbar-context'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { createContext, useState } from 'react'
|
||||
|
||||
export const historyToolbarStateContext = createContext<HistoryToolbarStateWithDispatcher | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* Provides a {@link React.Context react context} for the current state of the toolbar.
|
||||
*
|
||||
* @param children The children that should receive the toolbar state via context.
|
||||
*/
|
||||
export const HistoryToolbarStateContextProvider: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||
const search = useSingleStringUrlParameter('search', '')
|
||||
const selectedTags = useArrayStringUrlParameter('selectedTags')
|
||||
|
||||
const stateWithDispatcher = useState<HistoryToolbarState>(() => ({
|
||||
viewState: ViewStateEnum.CARD,
|
||||
search: search,
|
||||
selectedTags: selectedTags,
|
||||
titleSortDirection: SortModeEnum.no,
|
||||
lastVisitedSortDirection: SortModeEnum.down
|
||||
}))
|
||||
|
||||
return (
|
||||
<historyToolbarStateContext.Provider value={stateWithDispatcher}>{children}</historyToolbarStateContext.Provider>
|
||||
)
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryToolbarState } from '../history-toolbar-state'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
export type HistoryToolbarStateWithDispatcher = [HistoryToolbarState, Dispatch<SetStateAction<HistoryToolbarState>>]
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { historyToolbarStateContext } from './history-toolbar-state-context-provider'
|
||||
import type { HistoryToolbarStateWithDispatcher } from './toolbar-context'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import { useContext } from 'react'
|
||||
|
||||
/**
|
||||
* Receives a {@link React.Context react context} for the history toolbar state.
|
||||
*
|
||||
* @throws Error if no context was set
|
||||
*/
|
||||
export const useHistoryToolbarState: () => HistoryToolbarStateWithDispatcher = () => {
|
||||
return Optional.ofNullable(useContext(historyToolbarStateContext)).orElseThrow(
|
||||
() => new Error('No toolbar context found. Did you forget to use the provider component?')
|
||||
)
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useHistoryToolbarState } from './use-history-toolbar-state'
|
||||
import equal from 'fast-deep-equal'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Pushes the current search and tag selection into the navigation history stack of the browser.
|
||||
*/
|
||||
export const useSyncToolbarStateToUrlEffect = (): void => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [state] = useHistoryToolbarState()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams || !pathname) {
|
||||
return
|
||||
}
|
||||
|
||||
const urlParameterSearch = searchParams.get('search') ?? ''
|
||||
const urlParameterSelectedTags = searchParams.getAll('selectedTags')
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
let shouldUpdate = false
|
||||
|
||||
if (!equal(state.search, urlParameterSearch)) {
|
||||
if (!state.search) {
|
||||
params.delete('search')
|
||||
} else {
|
||||
params.set('search', state.search)
|
||||
}
|
||||
shouldUpdate = true
|
||||
}
|
||||
if (!equal(state.selectedTags, urlParameterSelectedTags)) {
|
||||
params.delete('selectedTags')
|
||||
state.selectedTags.forEach((tag) => params.append('selectedTags', tag))
|
||||
shouldUpdate = true
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}
|
||||
}, [state, router, searchParams, pathname])
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.history-pin {
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
svg {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.pinned svg {
|
||||
color: #d43f3a;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import styles from './pin-button.module.scss'
|
||||
import React from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { PinFill as IconPinFill } from 'react-bootstrap-icons'
|
||||
|
||||
export interface PinButtonProps {
|
||||
isPinned: boolean
|
||||
onPinClick: () => void
|
||||
isDark: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a button with a pin icon.
|
||||
*
|
||||
* @param isPinned The initial state of this button.
|
||||
* @param onPinClick The callback, that is fired when the button is clicked.
|
||||
* @param isDark If the button should be rendered in dark or not.
|
||||
* @param className Additional classes directly given to the button
|
||||
*/
|
||||
export const PinButton: React.FC<PinButtonProps> = ({ isPinned, onPinClick, isDark, className }) => {
|
||||
return (
|
||||
<Button
|
||||
variant={isDark ? 'secondary' : 'secondary'}
|
||||
className={`${styles['history-pin']} ${className || ''} ${isPinned ? styles['pinned'] : ''}`}
|
||||
onClick={onPinClick}
|
||||
{...cypressId('history-entry-pin-button')}
|
||||
{...cypressAttribute('pinned', isPinned ? 'true' : 'false')}>
|
||||
<UiIcon icon={IconPinFill} />
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { IconButton } from '../../common/icon-button/icon-button'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import type { ButtonProps } from 'react-bootstrap'
|
||||
import { SortAlphaDown as IconSortAlphaDown, SortAlphaUp as IconSortAlphaUp, X as IconX } from 'react-bootstrap-icons'
|
||||
|
||||
export enum SortModeEnum {
|
||||
up = 1,
|
||||
down = -1,
|
||||
no = 0
|
||||
}
|
||||
|
||||
export interface SortButtonProps extends ButtonProps {
|
||||
onDirectionChange: (direction: SortModeEnum) => void
|
||||
direction: SortModeEnum
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the sorting direction based on the previous direction.
|
||||
*
|
||||
* @param direction The previous sorting direction
|
||||
* @return The new sorting direction
|
||||
*/
|
||||
const toggleDirection = (direction: SortModeEnum) => {
|
||||
switch (direction) {
|
||||
case SortModeEnum.no:
|
||||
return SortModeEnum.up
|
||||
case SortModeEnum.up:
|
||||
return SortModeEnum.down
|
||||
case SortModeEnum.down:
|
||||
default:
|
||||
return SortModeEnum.no
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a button to change the sorting order of a list.
|
||||
*
|
||||
* @param children The children elements that should be rendered inside the button
|
||||
* @param variant The variant of the button
|
||||
* @param onDirectionChange Callback that is fired when the sorting direction is changed
|
||||
* @param direction The sorting direction that is used
|
||||
*/
|
||||
export const SortButton: React.FC<SortButtonProps> = ({ children, onDirectionChange, direction }) => {
|
||||
const toggleSort = useCallback(() => {
|
||||
onDirectionChange(toggleDirection(direction))
|
||||
}, [direction, onDirectionChange])
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (direction) {
|
||||
case SortModeEnum.down:
|
||||
return IconSortAlphaDown
|
||||
case SortModeEnum.up:
|
||||
return IconSortAlphaUp
|
||||
case SortModeEnum.no:
|
||||
return IconX
|
||||
}
|
||||
}, [direction])
|
||||
|
||||
return (
|
||||
<IconButton onClick={toggleSort} variant={'secondary'} icon={icon} iconSize={1.5} border={true}>
|
||||
{children}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Hook that returns the title of a note in the history if present or the translation for "untitled" otherwise.
|
||||
*
|
||||
* @param entry The history entry containing a title property, that might be an empty string.
|
||||
* @return A memoized string containing either the title of the entry or the translated version of "untitled".
|
||||
*/
|
||||
export const useHistoryEntryTitle = (entry: HistoryEntryWithOrigin): string => {
|
||||
const { t } = useTranslation()
|
||||
return useMemo(() => {
|
||||
return entry.title !== '' ? entry.title : t('editor.untitledNote')
|
||||
}, [t, entry])
|
||||
}
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
import type { HistoryToolbarState } from './history-toolbar/history-toolbar-state'
|
||||
import { SortModeEnum } from './sort-button/sort-button'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
/**
|
||||
* Parses a given ISO formatted date string and outputs it as a date and time string.
|
||||
*
|
||||
* @param date The date in ISO format.
|
||||
* @return The date formatted as date and time string.
|
||||
*/
|
||||
export const formatHistoryDate = (date: string): string => DateTime.fromISO(date).toFormat('DDDD T')
|
||||
|
||||
/**
|
||||
* Applies sorting and filter rules that match a given toolbar state to a list of history entries.
|
||||
*
|
||||
* @param entries The history entries to sort and filter.
|
||||
* @param toolbarState The state of the history toolbar (sorting rules, keyword and tag input).
|
||||
* @return The list of filtered and sorted history entries.
|
||||
*/
|
||||
export const sortAndFilterEntries = (
|
||||
entries: HistoryEntryWithOrigin[],
|
||||
toolbarState: HistoryToolbarState
|
||||
): HistoryEntryWithOrigin[] => {
|
||||
const filteredBySelectedTagsEntries = filterBySelectedTags(entries, toolbarState.selectedTags)
|
||||
const filteredByKeywordSearchEntries = filterByKeywordSearch(filteredBySelectedTagsEntries, toolbarState.search)
|
||||
return sortEntries(filteredByKeywordSearchEntries, toolbarState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the given history entries by the given tags.
|
||||
*
|
||||
* @param entries The history entries to filter.
|
||||
* @param selectedTags The tags that were selected as filter criteria.
|
||||
* @return The list of filtered history entries.
|
||||
*/
|
||||
const filterBySelectedTags = (entries: HistoryEntryWithOrigin[], selectedTags: string[]): HistoryEntryWithOrigin[] => {
|
||||
return entries.filter((entry) => {
|
||||
return selectedTags.length === 0 || arrayCommonCheck(entry.tags, selectedTags)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the entries of array 1 are contained in array 2.
|
||||
*
|
||||
* @param array1 The first input array.
|
||||
* @param array2 The second input array.
|
||||
* @return true if all entries from array 1 are contained in array 2, false otherwise.
|
||||
*/
|
||||
const arrayCommonCheck = <T>(array1: T[], array2: T[]): boolean => {
|
||||
const foundElement = array1.find((element1) => array2.find((element2) => element2 === element1))
|
||||
return !!foundElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the given history entries by the given search term. Works case-insensitive.
|
||||
*
|
||||
* @param entries The history entries to filter.
|
||||
* @param keywords The search term.
|
||||
* @return The history entries that contain the search term in their title.
|
||||
*/
|
||||
const filterByKeywordSearch = (entries: HistoryEntryWithOrigin[], keywords: string): HistoryEntryWithOrigin[] => {
|
||||
const searchTerm = keywords.toLowerCase()
|
||||
return entries.filter((entry) => entry.title.toLowerCase().includes(searchTerm))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the given history entries by the sorting rules of the provided toolbar state.
|
||||
*
|
||||
* @param entries The history entries to sort.
|
||||
* @param viewState The toolbar state containing the sorting options.
|
||||
* @return The sorted history entries.
|
||||
*/
|
||||
const sortEntries = (entries: HistoryEntryWithOrigin[], viewState: HistoryToolbarState): HistoryEntryWithOrigin[] => {
|
||||
return entries.sort((firstEntry, secondEntry) => {
|
||||
if (firstEntry.pinStatus && !secondEntry.pinStatus) {
|
||||
return -1
|
||||
}
|
||||
if (!firstEntry.pinStatus && secondEntry.pinStatus) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (viewState.titleSortDirection !== SortModeEnum.no) {
|
||||
return firstEntry.title.localeCompare(secondEntry.title) * viewState.titleSortDirection
|
||||
}
|
||||
|
||||
if (viewState.lastVisitedSortDirection !== SortModeEnum.no) {
|
||||
if (firstEntry.lastVisitedAt > secondEntry.lastVisitedAt) {
|
||||
return 1 * viewState.lastVisitedSortDirection
|
||||
}
|
||||
if (firstEntry.lastVisitedAt < secondEntry.lastVisitedAt) {
|
||||
return -1 * viewState.lastVisitedSortDirection
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
|
||||
/**
|
||||
* A button that links to the history page.
|
||||
*/
|
||||
export const HistoryButton: React.FC = () => {
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
<Link href={'/history'}>
|
||||
<Button variant={'secondary'} size={'sm'}>
|
||||
<Trans i18nKey='landing.navigation.history' />
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
|
@ -7,7 +7,6 @@
|
|||
import React from 'react'
|
||||
import { Card } from 'react-bootstrap'
|
||||
import { NewNoteButton } from '../../common/new-note-button/new-note-button'
|
||||
import { HistoryButton } from '../../layout/app-bar/app-bar-elements/help-dropdown/history-button'
|
||||
import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { PermissionLevel } from '@hedgedoc/commons'
|
||||
|
@ -32,7 +31,6 @@ export const GuestCard: React.FC = () => {
|
|||
</Card.Title>
|
||||
<div className={'d-flex flex-row gap-2'}>
|
||||
<NewNoteButton />
|
||||
<HistoryButton />
|
||||
</div>
|
||||
{guestAccessLevel !== PermissionLevel.CREATE && (
|
||||
<div className={'text-muted mt-2 small'}>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue