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:
Erik Michelson 2025-05-17 23:27:15 +02:00
parent c0ce00b3f9
commit d67e44f540
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
75 changed files with 76 additions and 2727 deletions

View file

@ -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

View file

@ -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)

View file

@ -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])
}

View file

@ -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)

View file

@ -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'

View file

@ -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>)

View file

@ -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>

View file

@ -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) {

View file

@ -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) {

View file

@ -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)

View file

@ -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 (

View file

@ -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 (

View file

@ -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 (

View file

@ -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

View file

@ -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>
)

View file

@ -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>
)
}

View file

@ -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;
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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;
}

View file

@ -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>
)
}

View file

@ -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>
)
}
}

View file

@ -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>
)
}

View file

@ -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%;
}
}
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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])
}

View file

@ -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>
)
}

View file

@ -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}
/>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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}
/>
)
}

View file

@ -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>
)
}

View file

@ -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>>]

View file

@ -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?')
)
}

View file

@ -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])
}

View file

@ -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;
}
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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])
}

View file

@ -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
})
}

View file

@ -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>
)
}

View file

@ -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'}>