mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-13 22:54:42 -04:00
The History PR: I - Move to redux (#1156)
This commit is contained in:
parent
bba2b207c4
commit
8e5a667d18
24 changed files with 629 additions and 417 deletions
|
@ -38,10 +38,10 @@ describe('History', () => {
|
||||||
.first()
|
.first()
|
||||||
.as('pin-button')
|
.as('pin-button')
|
||||||
cy.get('@pin-button')
|
cy.get('@pin-button')
|
||||||
.should('not.have.class', 'pinned')
|
.should('have.class', 'pinned')
|
||||||
.click()
|
.click()
|
||||||
cy.get('@pin-button')
|
cy.get('@pin-button')
|
||||||
.should('have.class', 'pinned')
|
.should('not.have.class', 'pinned')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Table', () => {
|
it('Table', () => {
|
||||||
|
@ -51,10 +51,10 @@ describe('History', () => {
|
||||||
.first()
|
.first()
|
||||||
.as('pin-button')
|
.as('pin-button')
|
||||||
cy.get('@pin-button')
|
cy.get('@pin-button')
|
||||||
.should('not.have.class', 'pinned')
|
.should('have.class', 'pinned')
|
||||||
.click()
|
.click()
|
||||||
cy.get('@pin-button')
|
cy.get('@pin-button')
|
||||||
.should('have.class', 'pinned')
|
.should('not.have.class', 'pinned')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -71,7 +71,7 @@ describe('History', () => {
|
||||||
cy.get('.fa-thumb-tack')
|
cy.get('.fa-thumb-tack')
|
||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
cy.get('.modal-dialog')
|
cy.get('.notifications-area .toast')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -81,7 +81,7 @@ describe('History', () => {
|
||||||
cy.get('.fa-thumb-tack')
|
cy.get('.fa-thumb-tack')
|
||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
cy.get('.modal-dialog')
|
cy.get('.notifications-area .toast')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "29QLD0AmT-adevdOPECtqg",
|
"identifier": "29QLD0AmT-adevdOPECtqg",
|
||||||
"title": "HedgeDoc community call 2020-04-26",
|
"title": "HedgeDoc community call 2020-04-26",
|
||||||
"lastVisited": "2020-05-16T22:26:56.547Z",
|
"lastVisited": "2020-05-16T22:26:56.547Z",
|
||||||
|
"pinStatus": false,
|
||||||
"tags": [
|
"tags": [
|
||||||
"HedgeDoc",
|
"HedgeDoc",
|
||||||
"Community Call"
|
"Community Call"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "features",
|
"identifier": "features",
|
||||||
"title": "Features",
|
"title": "Features",
|
||||||
"lastVisited": "2020-05-31T15:20:36.088Z",
|
"lastVisited": "2020-05-31T15:20:36.088Z",
|
||||||
|
"pinStatus": true,
|
||||||
"tags": [
|
"tags": [
|
||||||
"features",
|
"features",
|
||||||
"cool",
|
"cool",
|
||||||
|
@ -19,15 +21,17 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ODakLc2MQkyyFc_Xmb53sg",
|
"identifier": "ODakLc2MQkyyFc_Xmb53sg",
|
||||||
"title": "HedgeDoc V2 API",
|
"title": "HedgeDoc V2 API",
|
||||||
"lastVisited": "2020-05-25T19:48:14.025Z",
|
"lastVisited": "2020-05-25T19:48:14.025Z",
|
||||||
|
"pinStatus": false,
|
||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "l8JuWxApTR6Fqa0LCrpnLg",
|
"identifier": "l8JuWxApTR6Fqa0LCrpnLg",
|
||||||
"title": "Community call - Let’s meet! (2020-06-06 18:00 UTC / 20:00 CEST)",
|
"title": "Community call - Let’s meet! (2020-06-06 18:00 UTC / 20:00 CEST)",
|
||||||
"lastVisited": "2020-05-24T16:04:36.433Z",
|
"lastVisited": "2020-05-24T16:04:36.433Z",
|
||||||
|
"pinStatus": false,
|
||||||
"tags": [
|
"tags": [
|
||||||
"agenda",
|
"agenda",
|
||||||
"HedgeDoc community",
|
"HedgeDoc community",
|
||||||
|
|
33
src/api/history/dto-methods.ts
Normal file
33
src/api/history/dto-methods.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HistoryEntry, HistoryEntryOrigin } from '../../redux/history/types'
|
||||||
|
import { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types'
|
||||||
|
|
||||||
|
export const historyEntryDtoToHistoryEntry = (entryDto: HistoryEntryDto): HistoryEntry => {
|
||||||
|
return {
|
||||||
|
origin: HistoryEntryOrigin.REMOTE,
|
||||||
|
title: entryDto.title,
|
||||||
|
pinStatus: entryDto.pinStatus,
|
||||||
|
identifier: entryDto.identifier,
|
||||||
|
tags: entryDto.tags,
|
||||||
|
lastVisited: entryDto.lastVisited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const historyEntryToHistoryEntryPutDto = (entry: HistoryEntry): HistoryEntryPutDto => {
|
||||||
|
return {
|
||||||
|
pinStatus: entry.pinStatus,
|
||||||
|
lastVisited: entry.lastVisited,
|
||||||
|
note: entry.identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const historyEntryToHistoryEntryUpdateDto = (entry: HistoryEntry): HistoryEntryUpdateDto => {
|
||||||
|
return {
|
||||||
|
pinStatus: entry.pinStatus
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,22 +4,37 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { HistoryEntry } from '../../components/history-page/history-page'
|
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
||||||
|
import { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types'
|
||||||
|
|
||||||
export const getHistory = async (): Promise<HistoryEntry[]> => {
|
export const getHistory = async (): Promise<HistoryEntryDto[]> => {
|
||||||
const response = await fetch(getApiUrl() + '/history')
|
const response = await fetch(getApiUrl() + '/history')
|
||||||
expectResponseCode(response)
|
expectResponseCode(response)
|
||||||
return await response.json() as Promise<HistoryEntry[]>
|
return await response.json() as Promise<HistoryEntryDto[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setHistory = async (entries: HistoryEntry[]): Promise<void> => {
|
export const postHistory = async (entries: HistoryEntryPutDto[]): Promise<void> => {
|
||||||
const response = await fetch(getApiUrl() + '/history', {
|
const response = await fetch(getApiUrl() + '/history', {
|
||||||
...defaultFetchConfig,
|
...defaultFetchConfig,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(entries)
|
||||||
history: entries
|
|
||||||
})
|
})
|
||||||
|
expectResponseCode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateHistoryEntryPinStatus = async (noteId: string, entry: HistoryEntryUpdateDto): Promise<void> => {
|
||||||
|
const response = await fetch(getApiUrl() + '/history/' + noteId, {
|
||||||
|
...defaultFetchConfig,
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(entry)
|
||||||
|
})
|
||||||
|
expectResponseCode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteHistoryEntry = async (noteId: string): Promise<void> => {
|
||||||
|
const response = await fetch(getApiUrl() + '/history/' + noteId, {
|
||||||
|
...defaultFetchConfig,
|
||||||
|
method: 'DELETE'
|
||||||
})
|
})
|
||||||
expectResponseCode(response)
|
expectResponseCode(response)
|
||||||
}
|
}
|
||||||
|
@ -31,21 +46,3 @@ export const deleteHistory = async (): Promise<void> => {
|
||||||
})
|
})
|
||||||
expectResponseCode(response)
|
expectResponseCode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateHistoryEntry = async (noteId: string, entry: HistoryEntry): Promise<HistoryEntry> => {
|
|
||||||
const response = await fetch(getApiUrl() + '/history/' + noteId, {
|
|
||||||
...defaultFetchConfig,
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(entry)
|
|
||||||
})
|
|
||||||
expectResponseCode(response)
|
|
||||||
return await response.json() as Promise<HistoryEntry>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteHistoryEntry = async (noteId: string): Promise<void> => {
|
|
||||||
const response = await fetch(getApiUrl() + '/history/' + noteId, {
|
|
||||||
...defaultFetchConfig,
|
|
||||||
method: 'DELETE'
|
|
||||||
})
|
|
||||||
expectResponseCode(response)
|
|
||||||
}
|
|
||||||
|
|
23
src/api/history/types.d.ts
vendored
Normal file
23
src/api/history/types.d.ts
vendored
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HistoryEntryPutDto {
|
||||||
|
note: string
|
||||||
|
pinStatus: boolean
|
||||||
|
lastVisited: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntryUpdateDto {
|
||||||
|
pinStatus: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntryDto {
|
||||||
|
identifier: string
|
||||||
|
title: string
|
||||||
|
lastVisited: string
|
||||||
|
tags: string[]
|
||||||
|
pinStatus: boolean
|
||||||
|
}
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import { loadAllConfig } from './configLoader'
|
import { loadAllConfig } from './configLoader'
|
||||||
import { setUpI18n } from './i18n'
|
import { setUpI18n } from './i18n'
|
||||||
|
import { refreshHistoryState } from '../../../redux/history/methods'
|
||||||
|
|
||||||
const customDelay: () => Promise<void> = async () => {
|
const customDelay: () => Promise<void> = async () => {
|
||||||
if (window.localStorage.getItem('customDelay')) {
|
if (window.localStorage.getItem('customDelay')) {
|
||||||
|
@ -27,6 +28,9 @@ export const createSetUpTaskList = (baseUrl: string): InitTask[] => {
|
||||||
}, {
|
}, {
|
||||||
name: 'Load config',
|
name: 'Load config',
|
||||||
task: loadAllConfig(baseUrl)
|
task: loadAllConfig(baseUrl)
|
||||||
|
}, {
|
||||||
|
name: 'Load history state',
|
||||||
|
task: refreshHistoryState()
|
||||||
}, {
|
}, {
|
||||||
name: 'Add Delay',
|
name: 'Add Delay',
|
||||||
task: customDelay()
|
task: customDelay()
|
||||||
|
|
|
@ -9,24 +9,28 @@ import { Dropdown } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||||
import { ShowIf } from '../../common/show-if/show-if'
|
import { ShowIf } from '../../common/show-if/show-if'
|
||||||
import { HistoryEntryOrigin } from '../history-page'
|
|
||||||
import { DeleteNoteItem } from './delete-note-item'
|
import { DeleteNoteItem } from './delete-note-item'
|
||||||
import './entry-menu.scss'
|
import './entry-menu.scss'
|
||||||
import { RemoveNoteEntryItem } from './remove-note-entry-item'
|
import { RemoveNoteEntryItem } from './remove-note-entry-item'
|
||||||
|
import { HistoryEntryOrigin } from '../../../redux/history/types'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import { ApplicationState } from '../../../redux'
|
||||||
|
|
||||||
export interface EntryMenuProps {
|
export interface EntryMenuProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string
|
title: string
|
||||||
location: HistoryEntryOrigin
|
origin: HistoryEntryOrigin
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, location, isDark, onRemove, onDelete, className }) => {
|
export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, origin, isDark, onRemove, onDelete, className }) => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
|
|
||||||
|
const userExists = useSelector((state: ApplicationState) => !!state.user)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown className={ `d-inline-flex ${ className || '' }` }>
|
<Dropdown className={ `d-inline-flex ${ className || '' }` }>
|
||||||
<Dropdown.Toggle variant={ isDark ? 'secondary' : 'light' } id={ `dropdown-card-${ id }` }
|
<Dropdown.Toggle variant={ isDark ? 'secondary' : 'light' } id={ `dropdown-card-${ id }` }
|
||||||
|
@ -40,13 +44,13 @@ export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, location, isDar
|
||||||
<Trans i18nKey="landing.history.menu.recentNotes"/>
|
<Trans i18nKey="landing.history.menu.recentNotes"/>
|
||||||
</Dropdown.Header>
|
</Dropdown.Header>
|
||||||
|
|
||||||
<ShowIf condition={ location === HistoryEntryOrigin.LOCAL }>
|
<ShowIf condition={ origin === HistoryEntryOrigin.LOCAL }>
|
||||||
<Dropdown.Item disabled>
|
<Dropdown.Item disabled>
|
||||||
<ForkAwesomeIcon icon="laptop" fixedWidth={ true } className="mx-2"/>
|
<ForkAwesomeIcon icon="laptop" fixedWidth={ true } className="mx-2"/>
|
||||||
<Trans i18nKey="landing.history.menu.entryLocal"/>
|
<Trans i18nKey="landing.history.menu.entryLocal"/>
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
</ShowIf>
|
</ShowIf>
|
||||||
<ShowIf condition={ location === HistoryEntryOrigin.REMOTE }>
|
<ShowIf condition={ origin === HistoryEntryOrigin.REMOTE }>
|
||||||
<Dropdown.Item disabled>
|
<Dropdown.Item disabled>
|
||||||
<ForkAwesomeIcon icon="cloud" fixedWidth={ true } className="mx-2"/>
|
<ForkAwesomeIcon icon="cloud" fixedWidth={ true } className="mx-2"/>
|
||||||
<Trans i18nKey="landing.history.menu.entryRemote"/>
|
<Trans i18nKey="landing.history.menu.entryRemote"/>
|
||||||
|
@ -54,9 +58,10 @@ export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, location, isDar
|
||||||
</ShowIf>
|
</ShowIf>
|
||||||
<RemoveNoteEntryItem onConfirm={ onRemove } noteTitle={ title }/>
|
<RemoveNoteEntryItem onConfirm={ onRemove } noteTitle={ title }/>
|
||||||
|
|
||||||
|
<ShowIf condition={ userExists }>
|
||||||
<Dropdown.Divider/>
|
<Dropdown.Divider/>
|
||||||
|
|
||||||
<DeleteNoteItem onConfirm={ onDelete } noteTitle={ title }/>
|
<DeleteNoteItem onConfirm={ onDelete } noteTitle={ title }/>
|
||||||
|
</ShowIf>
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
)
|
)
|
||||||
|
|
|
@ -7,17 +7,17 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Row } from 'react-bootstrap'
|
import { Row } from 'react-bootstrap'
|
||||||
import { Pager } from '../../common/pagination/pager'
|
import { Pager } from '../../common/pagination/pager'
|
||||||
import { HistoryEntriesProps } from '../history-content/history-content'
|
import { HistoryEntriesProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||||
import { HistoryCard } from './history-card'
|
import { HistoryCard } from './history-card'
|
||||||
|
|
||||||
export const HistoryCardList: React.FC<HistoryEntriesProps> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => {
|
export const HistoryCardList: React.FC<HistoryEntriesProps & HistoryEventHandlers> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => {
|
||||||
return (
|
return (
|
||||||
<Row className="justify-content-start">
|
<Row className="justify-content-start">
|
||||||
<Pager numberOfElementsPerPage={ 9 } pageIndex={ pageIndex } onLastPageIndexChange={ onLastPageIndexChange }>
|
<Pager numberOfElementsPerPage={ 9 } pageIndex={ pageIndex } onLastPageIndexChange={ onLastPageIndexChange }>
|
||||||
{
|
{
|
||||||
entries.map((entry) => (
|
entries.map((entry) => (
|
||||||
<HistoryCard
|
<HistoryCard
|
||||||
key={ entry.id }
|
key={ entry.identifier }
|
||||||
entry={ entry }
|
entry={ entry }
|
||||||
onPinClick={ onPinClick }
|
onPinClick={ onPinClick }
|
||||||
onRemoveClick={ onRemoveClick }
|
onRemoveClick={ onRemoveClick }
|
||||||
|
|
|
@ -5,26 +5,34 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import React from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { Badge, Card } from 'react-bootstrap'
|
import { Badge, Card } from 'react-bootstrap'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||||
import { EntryMenu } from '../entry-menu/entry-menu'
|
import { EntryMenu } from '../entry-menu/entry-menu'
|
||||||
import { HistoryEntryProps } from '../history-content/history-content'
|
import { HistoryEntryProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||||
import { PinButton } from '../pin-button/pin-button'
|
import { PinButton } from '../pin-button/pin-button'
|
||||||
import { formatHistoryDate } from '../utils'
|
import { formatHistoryDate } from '../utils'
|
||||||
import './history-card.scss'
|
import './history-card.scss'
|
||||||
|
|
||||||
export const HistoryCard: React.FC<HistoryEntryProps> = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => {
|
export const HistoryCard: React.FC<HistoryEntryProps & HistoryEventHandlers> = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => {
|
||||||
|
const onRemove = useCallback(() => {
|
||||||
|
onRemoveClick(entry.identifier)
|
||||||
|
}, [onRemoveClick, entry.identifier])
|
||||||
|
|
||||||
|
const onDelete = useCallback(() => {
|
||||||
|
onDeleteClick(entry.identifier)
|
||||||
|
}, [onDeleteClick, entry.identifier])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 col-xs-12 col-sm-6 col-md-6 col-lg-4">
|
<div className="p-2 col-xs-12 col-sm-6 col-md-6 col-lg-4">
|
||||||
<Card className="card-min-height" text={ 'dark' } bg={ 'light' }>
|
<Card className="card-min-height" text={ 'dark' } bg={ 'light' }>
|
||||||
<Card.Body className="p-2 d-flex flex-row justify-content-between">
|
<Card.Body className="p-2 d-flex flex-row justify-content-between">
|
||||||
<div className={ 'd-flex flex-column' }>
|
<div className={ 'd-flex flex-column' }>
|
||||||
<PinButton isDark={ false } isPinned={ entry.pinned }
|
<PinButton isDark={ false } isPinned={ entry.pinStatus }
|
||||||
onPinClick={ () => onPinClick(entry.id, entry.location) }/>
|
onPinClick={ () => onPinClick(entry.identifier) }/>
|
||||||
</div>
|
</div>
|
||||||
<Link to={ `/n/${ entry.id }` } className="text-decoration-none flex-fill text-dark">
|
<Link to={ `/n/${ entry.identifier }` } className="text-decoration-none flex-fill text-dark">
|
||||||
<div className={ 'd-flex flex-column justify-content-between' }>
|
<div className={ 'd-flex flex-column justify-content-between' }>
|
||||||
<Card.Title className="m-0 mt-1dot5">{ entry.title }</Card.Title>
|
<Card.Title className="m-0 mt-1dot5">{ entry.title }</Card.Title>
|
||||||
<div>
|
<div>
|
||||||
|
@ -44,12 +52,12 @@ export const HistoryCard: React.FC<HistoryEntryProps> = ({ entry, onPinClick, on
|
||||||
</Link>
|
</Link>
|
||||||
<div className={ 'd-flex flex-column' }>
|
<div className={ 'd-flex flex-column' }>
|
||||||
<EntryMenu
|
<EntryMenu
|
||||||
id={ entry.id }
|
id={ entry.identifier }
|
||||||
title={ entry.title }
|
title={ entry.title }
|
||||||
location={ entry.location }
|
origin={ entry.origin }
|
||||||
isDark={ false }
|
isDark={ false }
|
||||||
onRemove={ () => onRemoveClick(entry.id, entry.location) }
|
onRemove={ onRemove }
|
||||||
onDelete={ () => onDeleteClick(entry.id, entry.location) }
|
onDelete={ onDelete }
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
|
|
|
@ -4,46 +4,67 @@
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Fragment, useState } from 'react'
|
import React, { Fragment, useCallback, useState } from 'react'
|
||||||
import { Alert, Row } from 'react-bootstrap'
|
import { Alert, Row } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { PagerPagination } from '../../common/pagination/pager-pagination'
|
import { PagerPagination } from '../../common/pagination/pager-pagination'
|
||||||
import { HistoryCardList } from '../history-card/history-card-list'
|
import { HistoryCardList } from '../history-card/history-card-list'
|
||||||
import { HistoryEntryOrigin, LocatedHistoryEntry } from '../history-page'
|
|
||||||
import { HistoryTable } from '../history-table/history-table'
|
import { HistoryTable } from '../history-table/history-table'
|
||||||
import { ViewStateEnum } from '../history-toolbar/history-toolbar'
|
import { ViewStateEnum } from '../history-toolbar/history-toolbar'
|
||||||
|
import { HistoryEntry } from '../../../redux/history/types'
|
||||||
|
import { removeHistoryEntry, toggleHistoryEntryPinning } from '../../../redux/history/methods'
|
||||||
|
import { deleteNote } from '../../../api/notes'
|
||||||
|
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||||
|
|
||||||
type OnEntryClick = (entryId: string, location: HistoryEntryOrigin) => void
|
type OnEntryClick = (entryId: string) => void
|
||||||
|
|
||||||
|
export interface HistoryEventHandlers {
|
||||||
|
onPinClick: OnEntryClick
|
||||||
|
onRemoveClick: OnEntryClick
|
||||||
|
onDeleteClick: OnEntryClick
|
||||||
|
}
|
||||||
|
|
||||||
export interface HistoryContentProps {
|
export interface HistoryContentProps {
|
||||||
viewState: ViewStateEnum
|
viewState: ViewStateEnum
|
||||||
entries: LocatedHistoryEntry[]
|
entries: HistoryEntry[]
|
||||||
onPinClick: OnEntryClick
|
|
||||||
onRemoveClick: OnEntryClick
|
|
||||||
onDeleteClick: OnEntryClick
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryEntryProps {
|
export interface HistoryEntryProps {
|
||||||
entry: LocatedHistoryEntry,
|
entry: HistoryEntry,
|
||||||
onPinClick: OnEntryClick
|
|
||||||
onRemoveClick: OnEntryClick
|
|
||||||
onDeleteClick: OnEntryClick
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryEntriesProps {
|
export interface HistoryEntriesProps {
|
||||||
entries: LocatedHistoryEntry[]
|
entries: HistoryEntry[]
|
||||||
onPinClick: OnEntryClick
|
|
||||||
onRemoveClick: OnEntryClick
|
|
||||||
onDeleteClick: OnEntryClick
|
|
||||||
pageIndex: number
|
pageIndex: number
|
||||||
onLastPageIndexChange: (lastPageIndex: number) => void
|
onLastPageIndexChange: (lastPageIndex: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistoryContent: React.FC<HistoryContentProps> = ({ viewState, entries, onPinClick, onRemoveClick, onDeleteClick }) => {
|
export const HistoryContent: React.FC<HistoryContentProps> = ({ viewState, entries }) => {
|
||||||
useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [pageIndex, setPageIndex] = useState(0)
|
const [pageIndex, setPageIndex] = useState(0)
|
||||||
const [lastPageIndex, setLastPageIndex] = useState(0)
|
const [lastPageIndex, setLastPageIndex] = useState(0)
|
||||||
|
|
||||||
|
const onPinClick = useCallback((noteId: string) => {
|
||||||
|
toggleHistoryEntryPinning(noteId).catch(
|
||||||
|
showErrorNotification(t('landing.history.error.updateEntry.text'))
|
||||||
|
)
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const onDeleteClick = useCallback((noteId: string) => {
|
||||||
|
deleteNote(noteId).then(() => {
|
||||||
|
return removeHistoryEntry(noteId)
|
||||||
|
}).catch(
|
||||||
|
showErrorNotification(t('landing.history.error.deleteNote.text'))
|
||||||
|
)
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const onRemoveClick = useCallback((noteId: string) => {
|
||||||
|
removeHistoryEntry(noteId).catch(
|
||||||
|
showErrorNotification(t('landing.history.error.deleteEntry.text'))
|
||||||
|
)
|
||||||
|
}, [t])
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Row className={ 'justify-content-center' }>
|
<Row className={ 'justify-content-center' }>
|
||||||
|
|
|
@ -4,226 +4,48 @@
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { Fragment, useEffect, useMemo, useState } from 'react'
|
||||||
import { Row } from 'react-bootstrap'
|
import { Row } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import { deleteHistory, deleteHistoryEntry, getHistory, setHistory, updateHistoryEntry } from '../../api/history'
|
|
||||||
import { deleteNote } from '../../api/notes'
|
|
||||||
import { ApplicationState } from '../../redux'
|
import { ApplicationState } from '../../redux'
|
||||||
import { download } from '../common/download/download'
|
|
||||||
import { ErrorModal } from '../common/modals/error-modal'
|
|
||||||
import { HistoryContent } from './history-content/history-content'
|
import { HistoryContent } from './history-content/history-content'
|
||||||
import { HistoryToolbar, HistoryToolbarState, initState as toolbarInitState } from './history-toolbar/history-toolbar'
|
import { HistoryToolbar, HistoryToolbarState, initState as toolbarInitState } from './history-toolbar/history-toolbar'
|
||||||
|
import { sortAndFilterEntries } from './utils'
|
||||||
import {
|
import { refreshHistoryState } from '../../redux/history/methods'
|
||||||
collectEntries,
|
import { HistoryEntry } from '../../redux/history/types'
|
||||||
loadHistoryFromLocalStore,
|
import { showErrorNotification } from '../../redux/ui-notifications/methods'
|
||||||
mergeEntryArrays,
|
|
||||||
setHistoryToLocalStore,
|
|
||||||
sortAndFilterEntries
|
|
||||||
} from './utils'
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
|
||||||
id: string,
|
|
||||||
title: string,
|
|
||||||
lastVisited: string,
|
|
||||||
tags: string[],
|
|
||||||
pinned: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryJson {
|
|
||||||
version: number,
|
|
||||||
entries: HistoryEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocatedHistoryEntry = HistoryEntry & HistoryEntryLocation
|
|
||||||
|
|
||||||
export interface HistoryEntryLocation {
|
|
||||||
location: HistoryEntryOrigin
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum HistoryEntryOrigin {
|
|
||||||
LOCAL = 'local',
|
|
||||||
REMOTE = 'remote'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HistoryPage: React.FC = () => {
|
export const HistoryPage: React.FC = () => {
|
||||||
useTranslation()
|
const { t } = useTranslation()
|
||||||
const [localHistoryEntries, setLocalHistoryEntries] = useState<HistoryEntry[]>(loadHistoryFromLocalStore)
|
|
||||||
const [remoteHistoryEntries, setRemoteHistoryEntries] = useState<HistoryEntry[]>([])
|
const allEntries = useSelector((state: ApplicationState) => state.history)
|
||||||
const [toolbarState, setToolbarState] = useState<HistoryToolbarState>(toolbarInitState)
|
const [toolbarState, setToolbarState] = useState<HistoryToolbarState>(toolbarInitState)
|
||||||
const userExists = useSelector((state: ApplicationState) => !!state.user)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
const historyWrite = useCallback((entries: HistoryEntry[]) => {
|
const entriesToShow = useMemo<HistoryEntry[]>(() =>
|
||||||
if (!entries) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setHistoryToLocalStore(entries)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
historyWrite(localHistoryEntries)
|
|
||||||
}, [historyWrite, localHistoryEntries])
|
|
||||||
|
|
||||||
const importHistory = useCallback((entries: HistoryEntry[]): void => {
|
|
||||||
if (userExists) {
|
|
||||||
setHistory(entries)
|
|
||||||
.then(() => setRemoteHistoryEntries(entries))
|
|
||||||
.catch(() => setError('setHistory'))
|
|
||||||
} else {
|
|
||||||
setLocalHistoryEntries(entries)
|
|
||||||
}
|
|
||||||
}, [userExists])
|
|
||||||
|
|
||||||
const refreshHistory = useCallback(() => {
|
|
||||||
const localHistory = loadHistoryFromLocalStore()
|
|
||||||
setLocalHistoryEntries(localHistory)
|
|
||||||
if (userExists) {
|
|
||||||
getHistory()
|
|
||||||
.then((remoteHistory) => setRemoteHistoryEntries(remoteHistory))
|
|
||||||
.catch(() => setError('getHistory'))
|
|
||||||
}
|
|
||||||
}, [userExists])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refreshHistory()
|
|
||||||
}, [refreshHistory])
|
|
||||||
|
|
||||||
const exportHistory = useCallback(() => {
|
|
||||||
const dataObject: HistoryJson = {
|
|
||||||
version: 2,
|
|
||||||
entries: mergeEntryArrays(localHistoryEntries, remoteHistoryEntries)
|
|
||||||
}
|
|
||||||
download(JSON.stringify(dataObject), `history_${ (new Date()).getTime() }.json`, 'application/json')
|
|
||||||
}, [localHistoryEntries, remoteHistoryEntries])
|
|
||||||
|
|
||||||
const clearHistory = useCallback(() => {
|
|
||||||
setLocalHistoryEntries([])
|
|
||||||
if (userExists) {
|
|
||||||
deleteHistory()
|
|
||||||
.then(() => setRemoteHistoryEntries([]))
|
|
||||||
.catch(() => setError('deleteHistory'))
|
|
||||||
}
|
|
||||||
historyWrite([])
|
|
||||||
}, [historyWrite, userExists])
|
|
||||||
|
|
||||||
const uploadAll = useCallback((): void => {
|
|
||||||
const newHistory = mergeEntryArrays(localHistoryEntries, remoteHistoryEntries)
|
|
||||||
if (userExists) {
|
|
||||||
setHistory(newHistory)
|
|
||||||
.then(() => {
|
|
||||||
setRemoteHistoryEntries(newHistory)
|
|
||||||
setLocalHistoryEntries([])
|
|
||||||
historyWrite([])
|
|
||||||
})
|
|
||||||
.catch(() => setError('setHistory'))
|
|
||||||
}
|
|
||||||
}, [historyWrite, localHistoryEntries, remoteHistoryEntries, userExists])
|
|
||||||
|
|
||||||
const removeFromHistoryClick = useCallback((entryId: string, location: HistoryEntryOrigin): void => {
|
|
||||||
if (location === HistoryEntryOrigin.LOCAL) {
|
|
||||||
setLocalHistoryEntries((entries) => entries.filter(entry => entry.id !== entryId))
|
|
||||||
} else if (location === HistoryEntryOrigin.REMOTE) {
|
|
||||||
deleteHistoryEntry(entryId)
|
|
||||||
.then(() => setRemoteHistoryEntries((entries) => entries.filter(entry => entry.id !== entryId)))
|
|
||||||
.catch(() => setError('deleteEntry'))
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const deleteNoteClick = useCallback((entryId: string, location: HistoryEntryOrigin): void => {
|
|
||||||
if (userExists) {
|
|
||||||
deleteNote(entryId)
|
|
||||||
.then(() => {
|
|
||||||
removeFromHistoryClick(entryId, location)
|
|
||||||
})
|
|
||||||
.catch(() => setError('deleteNote'))
|
|
||||||
}
|
|
||||||
}, [userExists, removeFromHistoryClick])
|
|
||||||
|
|
||||||
const pinClick = useCallback((entryId: string, location: HistoryEntryOrigin): void => {
|
|
||||||
if (location === HistoryEntryOrigin.LOCAL) {
|
|
||||||
setLocalHistoryEntries((entries) => {
|
|
||||||
return entries.map((entry) => {
|
|
||||||
if (entry.id === entryId) {
|
|
||||||
entry.pinned = !entry.pinned
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else if (location === HistoryEntryOrigin.REMOTE) {
|
|
||||||
const foundEntry = remoteHistoryEntries.find(entry => entry.id === entryId)
|
|
||||||
if (!foundEntry) {
|
|
||||||
setError('notFoundEntry')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const changedEntry = {
|
|
||||||
...foundEntry,
|
|
||||||
pinned: !foundEntry.pinned
|
|
||||||
}
|
|
||||||
updateHistoryEntry(entryId, changedEntry)
|
|
||||||
.then(() => setRemoteHistoryEntries((entries) => (
|
|
||||||
entries.map((entry) => {
|
|
||||||
if (entry.id === entryId) {
|
|
||||||
entry.pinned = !entry.pinned
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
})
|
|
||||||
)
|
|
||||||
))
|
|
||||||
.catch(() => setError('updateEntry'))
|
|
||||||
}
|
|
||||||
}, [remoteHistoryEntries])
|
|
||||||
|
|
||||||
const resetError = () => {
|
|
||||||
setError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const allEntries = useMemo(() => {
|
|
||||||
return collectEntries(localHistoryEntries, remoteHistoryEntries)
|
|
||||||
}, [localHistoryEntries, remoteHistoryEntries])
|
|
||||||
|
|
||||||
const tags = useMemo<string[]>(() => {
|
|
||||||
return allEntries.map(entry => entry.tags)
|
|
||||||
.reduce((a, b) => ([...a, ...b]), [])
|
|
||||||
.filter((value, index, array) => {
|
|
||||||
if (index === 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return (value !== array[index - 1])
|
|
||||||
})
|
|
||||||
}, [allEntries])
|
|
||||||
|
|
||||||
const entriesToShow = useMemo<LocatedHistoryEntry[]>(() =>
|
|
||||||
sortAndFilterEntries(allEntries, toolbarState),
|
sortAndFilterEntries(allEntries, toolbarState),
|
||||||
[allEntries, toolbarState])
|
[allEntries, toolbarState])
|
||||||
|
|
||||||
return <Fragment>
|
useEffect(() => {
|
||||||
<ErrorModal show={ error !== '' } onHide={ resetError }
|
refreshHistoryState().catch(
|
||||||
titleI18nKey={ error !== '' ? `landing.history.error.${ error }.title` : '' }>
|
showErrorNotification(t('landing.history.error.getHistory.text'))
|
||||||
<h5>
|
)
|
||||||
<Trans i18nKey={ error !== '' ? `landing.history.error.${ error }.text` : '' }/>
|
}, [t])
|
||||||
</h5>
|
|
||||||
</ErrorModal>
|
return (
|
||||||
<h1 className="mb-4"><Trans i18nKey="landing.navigation.history"/></h1>
|
<Fragment>
|
||||||
|
<h1 className="mb-4">
|
||||||
|
<Trans i18nKey="landing.navigation.history"/>
|
||||||
|
</h1>
|
||||||
<Row className={ 'justify-content-center mt-5 mb-3' }>
|
<Row className={ 'justify-content-center mt-5 mb-3' }>
|
||||||
<HistoryToolbar
|
<HistoryToolbar
|
||||||
onSettingsChange={ setToolbarState }
|
onSettingsChange={ setToolbarState }
|
||||||
tags={ tags }
|
|
||||||
onClearHistory={ clearHistory }
|
|
||||||
onRefreshHistory={ refreshHistory }
|
|
||||||
onExportHistory={ exportHistory }
|
|
||||||
onImportHistory={ importHistory }
|
|
||||||
onUploadAll={ uploadAll }
|
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<HistoryContent
|
<HistoryContent
|
||||||
viewState={ toolbarState.viewState }
|
viewState={ toolbarState.viewState }
|
||||||
entries={ entriesToShow }
|
entries={ entriesToShow }
|
||||||
onPinClick={ pinClick }
|
|
||||||
onRemoveClick={ removeFromHistoryClick }
|
|
||||||
onDeleteClick={ deleteNoteClick }
|
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,15 +8,15 @@ import React from 'react'
|
||||||
import { Badge } from 'react-bootstrap'
|
import { Badge } from 'react-bootstrap'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { EntryMenu } from '../entry-menu/entry-menu'
|
import { EntryMenu } from '../entry-menu/entry-menu'
|
||||||
import { HistoryEntryProps } from '../history-content/history-content'
|
import { HistoryEntryProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||||
import { PinButton } from '../pin-button/pin-button'
|
import { PinButton } from '../pin-button/pin-button'
|
||||||
import { formatHistoryDate } from '../utils'
|
import { formatHistoryDate } from '../utils'
|
||||||
|
|
||||||
export const HistoryTableRow: React.FC<HistoryEntryProps> = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => {
|
export const HistoryTableRow: React.FC<HistoryEntryProps & HistoryEventHandlers> = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<Link to={ `/n/${ entry.id }` } className="text-light">
|
<Link to={ `/n/${ entry.identifier }` } className="text-light">
|
||||||
{ entry.title }
|
{ entry.title }
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
@ -28,15 +28,15 @@ export const HistoryTableRow: React.FC<HistoryEntryProps> = ({ entry, onPinClick
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<PinButton isDark={ true } isPinned={ entry.pinned } onPinClick={ () => onPinClick(entry.id, entry.location) }
|
<PinButton isDark={ true } isPinned={ entry.pinStatus } onPinClick={ () => onPinClick(entry.identifier) }
|
||||||
className={ 'mb-1 mr-1' }/>
|
className={ 'mb-1 mr-1' }/>
|
||||||
<EntryMenu
|
<EntryMenu
|
||||||
id={ entry.id }
|
id={ entry.identifier }
|
||||||
title={ entry.title }
|
title={ entry.title }
|
||||||
location={ entry.location }
|
origin={ entry.origin }
|
||||||
isDark={ true }
|
isDark={ true }
|
||||||
onRemove={ () => onRemoveClick(entry.id, entry.location) }
|
onRemove={ () => onRemoveClick(entry.identifier) }
|
||||||
onDelete={ () => onDeleteClick(entry.id, entry.location) }
|
onDelete={ () => onDeleteClick(entry.identifier) }
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -8,11 +8,11 @@ import React from 'react'
|
||||||
import { Table } from 'react-bootstrap'
|
import { Table } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { Pager } from '../../common/pagination/pager'
|
import { Pager } from '../../common/pagination/pager'
|
||||||
import { HistoryEntriesProps } from '../history-content/history-content'
|
import { HistoryEntriesProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||||
import { HistoryTableRow } from './history-table-row'
|
import { HistoryTableRow } from './history-table-row'
|
||||||
import './history-table.scss'
|
import './history-table.scss'
|
||||||
|
|
||||||
export const HistoryTable: React.FC<HistoryEntriesProps> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => {
|
export const HistoryTable: React.FC<HistoryEntriesProps & HistoryEventHandlers> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
return (
|
return (
|
||||||
<Table striped bordered hover size="sm" variant="dark" className={ 'history-table' }>
|
<Table striped bordered hover size="sm" variant="dark" className={ 'history-table' }>
|
||||||
|
@ -29,7 +29,7 @@ export const HistoryTable: React.FC<HistoryEntriesProps> = ({ entries, onPinClic
|
||||||
{
|
{
|
||||||
entries.map((entry) =>
|
entries.map((entry) =>
|
||||||
<HistoryTableRow
|
<HistoryTableRow
|
||||||
key={ entry.id }
|
key={ entry.identifier }
|
||||||
entry={ entry }
|
entry={ entry }
|
||||||
onPinClick={ onPinClick }
|
onPinClick={ onPinClick }
|
||||||
onRemoveClick={ onRemoveClick }
|
onRemoveClick={ onRemoveClick }
|
||||||
|
|
|
@ -4,33 +4,38 @@
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Fragment, useState } from 'react'
|
import React, { Fragment, useCallback, useState } from 'react'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||||
import { DeletionModal } from '../../common/modals/deletion-modal'
|
import { DeletionModal } from '../../common/modals/deletion-modal'
|
||||||
|
import { deleteAllHistoryEntries, refreshHistoryState } from '../../../redux/history/methods'
|
||||||
|
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||||
|
|
||||||
export interface ClearHistoryButtonProps {
|
export const ClearHistoryButton: React.FC = () => {
|
||||||
onClearHistory: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ClearHistoryButton: React.FC<ClearHistoryButtonProps> = ({ onClearHistory }) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
const handleShow = () => setShow(true)
|
const handleShow = () => setShow(true)
|
||||||
const handleClose = () => setShow(false)
|
const handleClose = () => setShow(false)
|
||||||
|
|
||||||
|
const onConfirm = useCallback(() => {
|
||||||
|
deleteAllHistoryEntries().catch(error => {
|
||||||
|
showErrorNotification(t('landing.history.error.deleteEntry.text'))(error)
|
||||||
|
refreshHistoryState().catch(
|
||||||
|
showErrorNotification(t('landing.history.error.getHistory.text'))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
handleClose()
|
||||||
|
}, [t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Button variant={ 'light' } title={ t('landing.history.toolbar.clear') } onClick={ handleShow }>
|
<Button variant={ 'light' } title={ t('landing.history.toolbar.clear') } onClick={ handleShow }>
|
||||||
<ForkAwesomeIcon icon={ 'trash' }/>
|
<ForkAwesomeIcon icon={ 'trash' }/>
|
||||||
</Button>
|
</Button>
|
||||||
<DeletionModal
|
<DeletionModal
|
||||||
onConfirm={ () => {
|
onConfirm={ onConfirm }
|
||||||
onClearHistory()
|
|
||||||
handleClose()
|
|
||||||
} }
|
|
||||||
deletionButtonI18nKey={ 'landing.history.toolbar.clear' }
|
deletionButtonI18nKey={ 'landing.history.toolbar.clear' }
|
||||||
show={ show }
|
show={ show }
|
||||||
onHide={ handleClose }
|
onHide={ handleClose }
|
||||||
|
|
|
@ -8,16 +8,13 @@ import React from 'react'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||||
|
import { downloadHistory } from '../../../redux/history/methods'
|
||||||
|
|
||||||
export interface ExportHistoryButtonProps {
|
export const ExportHistoryButton: React.FC = () => {
|
||||||
onExportHistory: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ExportHistoryButton: React.FC<ExportHistoryButtonProps> = ({ onExportHistory }) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant={ 'light' } title={ t('landing.history.toolbar.export') } onClick={ onExportHistory }>
|
<Button variant={ 'light' } title={ t('landing.history.toolbar.export') } onClick={ downloadHistory }>
|
||||||
<ForkAwesomeIcon icon='download'/>
|
<ForkAwesomeIcon icon='download'/>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ChangeEvent, useEffect, useState } from 'react'
|
import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Button, Form, FormControl, InputGroup, ToggleButton, ToggleButtonGroup } from 'react-bootstrap'
|
import { Button, Form, FormControl, InputGroup, ToggleButton, ToggleButtonGroup } from 'react-bootstrap'
|
||||||
import { Typeahead } from 'react-bootstrap-typeahead'
|
import { Typeahead } from 'react-bootstrap-typeahead'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
@ -12,12 +12,14 @@ import { useSelector } from 'react-redux'
|
||||||
import { ApplicationState } from '../../../redux'
|
import { ApplicationState } from '../../../redux'
|
||||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||||
import { ShowIf } from '../../common/show-if/show-if'
|
import { ShowIf } from '../../common/show-if/show-if'
|
||||||
import { HistoryEntry } from '../history-page'
|
|
||||||
import { SortButton, SortModeEnum } from '../sort-button/sort-button'
|
import { SortButton, SortModeEnum } from '../sort-button/sort-button'
|
||||||
import { ClearHistoryButton } from './clear-history-button'
|
import { ClearHistoryButton } from './clear-history-button'
|
||||||
import { ExportHistoryButton } from './export-history-button'
|
import { ExportHistoryButton } from './export-history-button'
|
||||||
import { ImportHistoryButton } from './import-history-button'
|
import { ImportHistoryButton } from './import-history-button'
|
||||||
import './typeahead-hacks.scss'
|
import './typeahead-hacks.scss'
|
||||||
|
import { HistoryEntryOrigin } from '../../../redux/history/types'
|
||||||
|
import { importHistoryEntries, refreshHistoryState, setHistoryEntries } from '../../../redux/history/methods'
|
||||||
|
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||||
|
|
||||||
export type HistoryToolbarChange = (settings: HistoryToolbarState) => void;
|
export type HistoryToolbarChange = (settings: HistoryToolbarState) => void;
|
||||||
|
|
||||||
|
@ -36,12 +38,6 @@ export enum ViewStateEnum {
|
||||||
|
|
||||||
export interface HistoryToolbarProps {
|
export interface HistoryToolbarProps {
|
||||||
onSettingsChange: HistoryToolbarChange
|
onSettingsChange: HistoryToolbarChange
|
||||||
tags: string[]
|
|
||||||
onClearHistory: () => void
|
|
||||||
onRefreshHistory: () => void
|
|
||||||
onExportHistory: () => void
|
|
||||||
onImportHistory: (entries: HistoryEntry[]) => void
|
|
||||||
onUploadAll: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initState: HistoryToolbarState = {
|
export const initState: HistoryToolbarState = {
|
||||||
|
@ -52,11 +48,18 @@ export const initState: HistoryToolbarState = {
|
||||||
selectedTags: []
|
selectedTags: []
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange, tags, onClearHistory, onRefreshHistory, onExportHistory, onImportHistory, onUploadAll }) => {
|
export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange }) => {
|
||||||
const [t] = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [state, setState] = useState<HistoryToolbarState>(initState)
|
const [state, setState] = useState<HistoryToolbarState>(initState)
|
||||||
|
const historyEntries = useSelector((state: ApplicationState) => state.history)
|
||||||
const userExists = useSelector((state: ApplicationState) => !!state.user)
|
const userExists = useSelector((state: ApplicationState) => !!state.user)
|
||||||
|
|
||||||
|
const tags = useMemo<string[]>(() => {
|
||||||
|
const allTags = historyEntries.map(entry => entry.tags)
|
||||||
|
.flat()
|
||||||
|
return [...new Set(allTags)]
|
||||||
|
}, [historyEntries])
|
||||||
|
|
||||||
const titleSortChanged = (direction: SortModeEnum) => {
|
const titleSortChanged = (direction: SortModeEnum) => {
|
||||||
setState(prevState => ({
|
setState(prevState => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
|
@ -85,6 +88,33 @@ export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange
|
||||||
setState(prevState => ({ ...prevState, selectedTags: selected }))
|
setState(prevState => ({ ...prevState, selectedTags: selected }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshHistory = useCallback(() => {
|
||||||
|
refreshHistoryState()
|
||||||
|
.catch(
|
||||||
|
showErrorNotification(t('landing.history.error.getHistory.text'))
|
||||||
|
)
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
showErrorNotification(t('landing.history.error.setHistory.text'))(error)
|
||||||
|
historyEntries.forEach(entry => {
|
||||||
|
if (localEntries.includes(entry.identifier)) {
|
||||||
|
entry.origin = HistoryEntryOrigin.LOCAL
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setHistoryEntries(historyEntries)
|
||||||
|
refreshHistory()
|
||||||
|
})
|
||||||
|
}, [userExists, historyEntries, t, refreshHistory])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSettingsChange(state)
|
onSettingsChange(state)
|
||||||
}, [onSettingsChange, state])
|
}, [onSettingsChange, state])
|
||||||
|
@ -113,28 +143,28 @@ export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange
|
||||||
variant={ 'light' }><Trans i18nKey={ 'landing.history.toolbar.sortByLastVisited' }/></SortButton>
|
variant={ 'light' }><Trans i18nKey={ 'landing.history.toolbar.sortByLastVisited' }/></SortButton>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup className={ 'mr-1 mb-1' }>
|
<InputGroup className={ 'mr-1 mb-1' }>
|
||||||
<ExportHistoryButton onExportHistory={ onExportHistory }/>
|
<ExportHistoryButton/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup className={ 'mr-1 mb-1' }>
|
<InputGroup className={ 'mr-1 mb-1' }>
|
||||||
<ImportHistoryButton onImportHistory={ onImportHistory }/>
|
<ImportHistoryButton/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup className={ 'mr-1 mb-1' }>
|
<InputGroup className={ 'mr-1 mb-1' }>
|
||||||
<ClearHistoryButton onClearHistory={ onClearHistory }/>
|
<ClearHistoryButton/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup className={ 'mr-1 mb-1' }>
|
<InputGroup className={ 'mr-1 mb-1' }>
|
||||||
<Button variant={ 'light' } title={ t('landing.history.toolbar.refresh') } onClick={ onRefreshHistory }>
|
<Button variant={ 'light' } title={ t('landing.history.toolbar.refresh') } onClick={ refreshHistory }>
|
||||||
<ForkAwesomeIcon icon='refresh'/>
|
<ForkAwesomeIcon icon="refresh"/>
|
||||||
</Button>
|
</Button>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<ShowIf condition={ userExists }>
|
<ShowIf condition={ userExists }>
|
||||||
<InputGroup className={ 'mr-1 mb-1' }>
|
<InputGroup className={ 'mr-1 mb-1' }>
|
||||||
<Button variant={ 'light' } title={ t('landing.history.toolbar.uploadAll') } onClick={ onUploadAll }>
|
<Button variant={ 'light' } title={ t('landing.history.toolbar.uploadAll') } onClick={ onUploadAllToRemote }>
|
||||||
<ForkAwesomeIcon icon='cloud-upload'/>
|
<ForkAwesomeIcon icon="cloud-upload"/>
|
||||||
</Button>
|
</Button>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</ShowIf>
|
</ShowIf>
|
||||||
<InputGroup className={ 'mr-1 mb-1' }>
|
<InputGroup className={ 'mr-1 mb-1' }>
|
||||||
<ToggleButtonGroup type="radio" name="options" dir='ltr' value={ state.viewState } className={ 'button-height' }
|
<ToggleButtonGroup type="radio" name="options" dir="ltr" value={ state.viewState } className={ 'button-height' }
|
||||||
onChange={ (newViewState: ViewStateEnum) => {
|
onChange={ (newViewState: ViewStateEnum) => {
|
||||||
toggleViewChanged(newViewState)
|
toggleViewChanged(newViewState)
|
||||||
} }>
|
} }>
|
||||||
|
|
|
@ -4,34 +4,50 @@
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef, useState } from 'react'
|
import React, { useCallback, useRef, useState } from 'react'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||||
import { ErrorModal } from '../../common/modals/error-modal'
|
import { ErrorModal } from '../../common/modals/error-modal'
|
||||||
import { HistoryEntry, HistoryJson } from '../history-page'
|
import { HistoryEntry, HistoryEntryOrigin, HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
|
||||||
import { convertV1History, V1HistoryEntry } from '../utils'
|
import {
|
||||||
|
convertV1History,
|
||||||
|
importHistoryEntries,
|
||||||
|
mergeHistoryEntries,
|
||||||
|
refreshHistoryState
|
||||||
|
} from '../../../redux/history/methods'
|
||||||
|
import { ApplicationState } from '../../../redux'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||||
|
|
||||||
export interface ImportHistoryButtonProps {
|
export const ImportHistoryButton: React.FC = () => {
|
||||||
onImportHistory: (entries: HistoryEntry[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ImportHistoryButton: React.FC<ImportHistoryButtonProps> = ({ onImportHistory }) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const userExists = useSelector((state: ApplicationState) => !!state.user)
|
||||||
|
const historyState = useSelector((state: ApplicationState) => state.history)
|
||||||
const uploadInput = useRef<HTMLInputElement>(null)
|
const uploadInput = useRef<HTMLInputElement>(null)
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
const [fileName, setFilename] = useState('')
|
const [fileName, setFilename] = useState('')
|
||||||
const [i18nKey, setI18nKey] = useState('')
|
const [i18nKey, setI18nKey] = useState('')
|
||||||
|
|
||||||
const handleShow = (key: string) => {
|
const handleShow = useCallback((key: string) => {
|
||||||
setI18nKey(key)
|
setI18nKey(key)
|
||||||
setShow(true)
|
setShow(true)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = useCallback(() => {
|
||||||
setI18nKey('')
|
setI18nKey('')
|
||||||
setShow(false)
|
setShow(false)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
const onImportHistory = useCallback((entries: HistoryEntry[]): void => {
|
||||||
|
entries.forEach(entry => entry.origin = userExists ? HistoryEntryOrigin.REMOTE : HistoryEntryOrigin.LOCAL)
|
||||||
|
importHistoryEntries(mergeHistoryEntries(historyState, entries)).catch(error => {
|
||||||
|
showErrorNotification(t('landing.history.error.setHistory.text'))(error)
|
||||||
|
refreshHistoryState().catch(
|
||||||
|
showErrorNotification(t('landing.history.error.getHistory.text'))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [historyState, userExists, t])
|
||||||
|
|
||||||
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { validity, files } = event.target
|
const { validity, files } = event.target
|
||||||
|
@ -47,7 +63,7 @@ export const ImportHistoryButton: React.FC<ImportHistoryButtonProps> = ({ onImpo
|
||||||
if (event.target && event.target.result) {
|
if (event.target && event.target.result) {
|
||||||
try {
|
try {
|
||||||
const result = event.target.result as string
|
const result = event.target.result as string
|
||||||
const data = JSON.parse(result) as HistoryJson
|
const data = JSON.parse(result) as HistoryExportJson
|
||||||
if (data) {
|
if (data) {
|
||||||
if (data.version) {
|
if (data.version) {
|
||||||
if (data.version === 2) {
|
if (data.version === 2) {
|
||||||
|
|
|
@ -6,47 +6,25 @@
|
||||||
|
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { SortModeEnum } from './sort-button/sort-button'
|
import { SortModeEnum } from './sort-button/sort-button'
|
||||||
import { HistoryEntry, HistoryEntryOrigin, LocatedHistoryEntry } from './history-page'
|
|
||||||
import { HistoryToolbarState } from './history-toolbar/history-toolbar'
|
import { HistoryToolbarState } from './history-toolbar/history-toolbar'
|
||||||
|
import { HistoryEntry } from '../../redux/history/types'
|
||||||
|
|
||||||
export function collectEntries(localEntries: HistoryEntry[], remoteEntries: HistoryEntry[]): LocatedHistoryEntry[] {
|
export const formatHistoryDate = (date: string): string => DateTime.fromISO(date).toFormat('DDDD T')
|
||||||
const locatedLocalEntries = locateEntries(localEntries, HistoryEntryOrigin.LOCAL)
|
|
||||||
const locatedRemoteEntries = locateEntries(remoteEntries, HistoryEntryOrigin.REMOTE)
|
|
||||||
return mergeEntryArrays(locatedLocalEntries, locatedRemoteEntries)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortAndFilterEntries(entries: LocatedHistoryEntry[], toolbarState: HistoryToolbarState): LocatedHistoryEntry[] {
|
export const sortAndFilterEntries = (entries: HistoryEntry[], toolbarState: HistoryToolbarState): HistoryEntry[] => {
|
||||||
const filteredBySelectedTagsEntries = filterBySelectedTags(entries, toolbarState.selectedTags)
|
const filteredBySelectedTagsEntries = filterBySelectedTags(entries, toolbarState.selectedTags)
|
||||||
const filteredByKeywordSearchEntries = filterByKeywordSearch(filteredBySelectedTagsEntries, toolbarState.keywordSearch)
|
const filteredByKeywordSearchEntries = filterByKeywordSearch(filteredBySelectedTagsEntries, toolbarState.keywordSearch)
|
||||||
return sortEntries(filteredByKeywordSearchEntries, toolbarState)
|
return sortEntries(filteredByKeywordSearchEntries, toolbarState)
|
||||||
}
|
}
|
||||||
|
|
||||||
function locateEntries(entries: HistoryEntry[], location: HistoryEntryOrigin): LocatedHistoryEntry[] {
|
const filterBySelectedTags = (entries: HistoryEntry[], selectedTags: string[]): HistoryEntry[] => {
|
||||||
return entries.map(entry => {
|
|
||||||
return {
|
|
||||||
...entry,
|
|
||||||
location: location
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeEntryArrays<T extends HistoryEntry>(localEntries: T[], remoteEntries: T[]): T[] {
|
|
||||||
const filteredLocalEntries = localEntries.filter(localEntry => {
|
|
||||||
const entry = remoteEntries.find(remoteEntry => remoteEntry.id === localEntry.id)
|
|
||||||
return !entry
|
|
||||||
})
|
|
||||||
|
|
||||||
return filteredLocalEntries.concat(remoteEntries)
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterBySelectedTags(entries: LocatedHistoryEntry[], selectedTags: string[]): LocatedHistoryEntry[] {
|
|
||||||
return entries.filter(entry => {
|
return entries.filter(entry => {
|
||||||
return (selectedTags.length === 0 || arrayCommonCheck(entry.tags, selectedTags))
|
return (selectedTags.length === 0 || arrayCommonCheck(entry.tags, selectedTags))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function arrayCommonCheck<T>(array1: T[], array2: T[]): boolean {
|
const arrayCommonCheck = <T> (array1: T[], array2: T[]): boolean => {
|
||||||
const foundElement = array1.find((element1) =>
|
const foundElement = array1.find((element1) =>
|
||||||
array2.find((element2) =>
|
array2.find((element2) =>
|
||||||
element2 === element1
|
element2 === element1
|
||||||
|
@ -55,18 +33,21 @@ function arrayCommonCheck<T>(array1: T[], array2: T[]): boolean {
|
||||||
return !!foundElement
|
return !!foundElement
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterByKeywordSearch(entries: LocatedHistoryEntry[], keywords: string): LocatedHistoryEntry[] {
|
const filterByKeywordSearch = (entries: HistoryEntry[], keywords: string): HistoryEntry[] => {
|
||||||
const searchTerm = keywords.toLowerCase()
|
const searchTerm = keywords.toLowerCase()
|
||||||
return entries.filter(entry => entry.title.toLowerCase()
|
return entries.filter(
|
||||||
.includes(searchTerm))
|
entry => entry.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortEntries(entries: LocatedHistoryEntry[], viewState: HistoryToolbarState): LocatedHistoryEntry[] {
|
const sortEntries = (entries: HistoryEntry[], viewState: HistoryToolbarState): HistoryEntry[] => {
|
||||||
return entries.sort((firstEntry, secondEntry) => {
|
return entries.sort((firstEntry, secondEntry) => {
|
||||||
if (firstEntry.pinned && !secondEntry.pinned) {
|
if (firstEntry.pinStatus && !secondEntry.pinStatus) {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
if (!firstEntry.pinned && secondEntry.pinned) {
|
if (!firstEntry.pinStatus && secondEntry.pinStatus) {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,47 +67,3 @@ function sortEntries(entries: LocatedHistoryEntry[], viewState: HistoryToolbarSt
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHistoryDate(date: string): string {
|
|
||||||
return DateTime.fromISO(date)
|
|
||||||
.toFormat('DDDD T')
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface V1HistoryEntry {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
time: number;
|
|
||||||
tags: string[];
|
|
||||||
pinned: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertV1History(oldHistory: V1HistoryEntry[]): HistoryEntry[] {
|
|
||||||
return oldHistory.map((entry: V1HistoryEntry) => {
|
|
||||||
return {
|
|
||||||
id: entry.id,
|
|
||||||
title: entry.text,
|
|
||||||
lastVisited: DateTime.fromMillis(entry.time)
|
|
||||||
.toISO(),
|
|
||||||
tags: entry.tags,
|
|
||||||
pinned: entry.pinned
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadHistoryFromLocalStore(): HistoryEntry[] {
|
|
||||||
const historyJsonString = window.localStorage.getItem('history')
|
|
||||||
|
|
||||||
if (!historyJsonString) {
|
|
||||||
// if localStorage["history"] is empty we check the old localStorage["notehistory"]
|
|
||||||
// and convert it to the new format
|
|
||||||
const oldHistoryJsonString = window.localStorage.getItem('notehistory')
|
|
||||||
const oldHistory = oldHistoryJsonString ? JSON.parse(JSON.parse(oldHistoryJsonString)) as V1HistoryEntry[] : []
|
|
||||||
return convertV1History(oldHistory)
|
|
||||||
} else {
|
|
||||||
return JSON.parse(historyJsonString) as HistoryEntry[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setHistoryToLocalStore(entries: HistoryEntry[]): void {
|
|
||||||
window.localStorage.setItem('history', JSON.stringify(entries))
|
|
||||||
}
|
|
||||||
|
|
197
src/redux/history/methods.ts
Normal file
197
src/redux/history/methods.ts
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { store } from '../index'
|
||||||
|
import {
|
||||||
|
HistoryActionType,
|
||||||
|
HistoryEntry,
|
||||||
|
HistoryEntryOrigin,
|
||||||
|
HistoryExportJson,
|
||||||
|
RemoveEntryAction,
|
||||||
|
SetEntriesAction,
|
||||||
|
UpdateEntryAction,
|
||||||
|
V1HistoryEntry
|
||||||
|
} from './types'
|
||||||
|
import { download } from '../../components/common/download/download'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
import {
|
||||||
|
deleteHistory,
|
||||||
|
deleteHistoryEntry,
|
||||||
|
getHistory,
|
||||||
|
postHistory,
|
||||||
|
updateHistoryEntryPinStatus
|
||||||
|
} from '../../api/history'
|
||||||
|
import {
|
||||||
|
historyEntryDtoToHistoryEntry,
|
||||||
|
historyEntryToHistoryEntryPutDto,
|
||||||
|
historyEntryToHistoryEntryUpdateDto
|
||||||
|
} from '../../api/history/dto-methods'
|
||||||
|
|
||||||
|
export const setHistoryEntries = (entries: HistoryEntry[]): void => {
|
||||||
|
store.dispatch({
|
||||||
|
type: HistoryActionType.SET_ENTRIES,
|
||||||
|
entries
|
||||||
|
} as SetEntriesAction)
|
||||||
|
storeLocalHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importHistoryEntries = (entries: HistoryEntry[]): Promise<void> => {
|
||||||
|
setHistoryEntries(entries)
|
||||||
|
return storeRemoteHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteAllHistoryEntries = (): Promise<void> => {
|
||||||
|
store.dispatch({
|
||||||
|
type: HistoryActionType.SET_ENTRIES,
|
||||||
|
entries: []
|
||||||
|
})
|
||||||
|
storeLocalHistory()
|
||||||
|
return deleteHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateHistoryEntryRedux = (noteId: string, newEntry: HistoryEntry): void => {
|
||||||
|
store.dispatch({
|
||||||
|
type: HistoryActionType.UPDATE_ENTRY,
|
||||||
|
noteId,
|
||||||
|
newEntry
|
||||||
|
} as UpdateEntryAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateLocalHistoryEntry = (noteId: string, newEntry: HistoryEntry): void => {
|
||||||
|
updateHistoryEntryRedux(noteId, newEntry)
|
||||||
|
storeLocalHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeHistoryEntry = async (noteId: string): Promise<void> => {
|
||||||
|
const entryToDelete = store.getState().history.find(entry => entry.identifier === noteId)
|
||||||
|
if (entryToDelete && entryToDelete.origin === HistoryEntryOrigin.REMOTE) {
|
||||||
|
await deleteHistoryEntry(noteId)
|
||||||
|
}
|
||||||
|
store.dispatch({
|
||||||
|
type: HistoryActionType.REMOVE_ENTRY,
|
||||||
|
noteId
|
||||||
|
} as RemoveEntryAction)
|
||||||
|
storeLocalHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleHistoryEntryPinning = async (noteId: string): Promise<void> => {
|
||||||
|
const state = store.getState().history
|
||||||
|
const entryToUpdate = state.find(entry => entry.identifier === noteId)
|
||||||
|
if (!entryToUpdate) {
|
||||||
|
return Promise.reject(`History entry for note '${ noteId }' not found`)
|
||||||
|
}
|
||||||
|
if (entryToUpdate.pinStatus === undefined) {
|
||||||
|
entryToUpdate.pinStatus = false
|
||||||
|
}
|
||||||
|
entryToUpdate.pinStatus = !entryToUpdate.pinStatus
|
||||||
|
if (entryToUpdate.origin === HistoryEntryOrigin.LOCAL) {
|
||||||
|
updateLocalHistoryEntry(noteId, entryToUpdate)
|
||||||
|
} else {
|
||||||
|
const historyUpdateDto = historyEntryToHistoryEntryUpdateDto(entryToUpdate)
|
||||||
|
updateHistoryEntryRedux(noteId, entryToUpdate)
|
||||||
|
await updateHistoryEntryPinStatus(noteId, historyUpdateDto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadHistory = (): void => {
|
||||||
|
const history = store.getState().history
|
||||||
|
history.forEach((entry: Partial<HistoryEntry>) => {
|
||||||
|
delete entry.origin
|
||||||
|
})
|
||||||
|
const json = JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
entries: history
|
||||||
|
} as HistoryExportJson)
|
||||||
|
download(json, `history_${ Date.now() }.json`, 'application/json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergeHistoryEntries = (a: HistoryEntry[], b: HistoryEntry[]): HistoryEntry[] => {
|
||||||
|
const noDuplicates = a.filter(entryA => !b.some(entryB => entryA.identifier === entryB.identifier))
|
||||||
|
return noDuplicates.concat(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntry[] => {
|
||||||
|
return oldHistory.map(entry => ({
|
||||||
|
identifier: entry.id,
|
||||||
|
title: entry.text,
|
||||||
|
tags: entry.tags,
|
||||||
|
lastVisited: DateTime.fromMillis(entry.time)
|
||||||
|
.toISO(),
|
||||||
|
pinStatus: entry.pinned,
|
||||||
|
origin: HistoryEntryOrigin.LOCAL
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const refreshHistoryState = async (): Promise<void> => {
|
||||||
|
const localEntries = loadLocalHistory()
|
||||||
|
if (!store.getState().user) {
|
||||||
|
setHistoryEntries(localEntries)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const remoteEntries = await loadRemoteHistory()
|
||||||
|
const allEntries = mergeHistoryEntries(localEntries, remoteEntries)
|
||||||
|
setHistoryEntries(allEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storeLocalHistory = (): void => {
|
||||||
|
const history = store.getState().history
|
||||||
|
const localEntries = history.filter(entry => entry.origin === HistoryEntryOrigin.LOCAL)
|
||||||
|
const entriesWithoutOrigin = localEntries.map(entry => ({
|
||||||
|
...entry,
|
||||||
|
origin: undefined
|
||||||
|
}))
|
||||||
|
window.localStorage.setItem('history', JSON.stringify(entriesWithoutOrigin))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storeRemoteHistory = (): Promise<void> => {
|
||||||
|
if (!store.getState().user) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
const history = store.getState().history
|
||||||
|
const remoteEntries = history.filter(entry => entry.origin === HistoryEntryOrigin.REMOTE)
|
||||||
|
const remoteEntryDtos = remoteEntries.map(historyEntryToHistoryEntryPutDto)
|
||||||
|
return postHistory(remoteEntryDtos)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLocalHistory = (): HistoryEntry[] => {
|
||||||
|
const localV1Json = window.localStorage.getItem('notehistory')
|
||||||
|
if (localV1Json) {
|
||||||
|
try {
|
||||||
|
const localV1History = JSON.parse(JSON.parse(localV1Json)) as V1HistoryEntry[]
|
||||||
|
window.localStorage.removeItem('notehistory')
|
||||||
|
return convertV1History(localV1History)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error converting old history entries: ${ String(error) }`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localJson = window.localStorage.getItem('history')
|
||||||
|
if (!localJson) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localHistory = JSON.parse(localJson) as HistoryEntry[]
|
||||||
|
localHistory.forEach(entry => {
|
||||||
|
entry.origin = HistoryEntryOrigin.LOCAL
|
||||||
|
})
|
||||||
|
return localHistory
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing local stored history entries: ${ String(error) }`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRemoteHistory = async (): Promise<HistoryEntry[]> => {
|
||||||
|
try {
|
||||||
|
const remoteHistory = await getHistory()
|
||||||
|
return remoteHistory.map(historyEntryDtoToHistoryEntry)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching history entries from server: ${ String(error) }`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
35
src/redux/history/reducers.ts
Normal file
35
src/redux/history/reducers.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Reducer } from 'redux'
|
||||||
|
import {
|
||||||
|
HistoryAction,
|
||||||
|
HistoryActionType,
|
||||||
|
HistoryEntry,
|
||||||
|
RemoveEntryAction,
|
||||||
|
SetEntriesAction,
|
||||||
|
UpdateEntryAction
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// Q: Why is the reducer initialized with an empty array instead of the actual history entries like in the config reducer?
|
||||||
|
// A: The history reducer will be created without entries because of async entry retrieval.
|
||||||
|
// Entries will be added after reducer initialization.
|
||||||
|
|
||||||
|
export const HistoryReducer: Reducer<HistoryEntry[], HistoryAction> = (state: HistoryEntry[] = [], action: HistoryAction) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case HistoryActionType.SET_ENTRIES:
|
||||||
|
return (action as SetEntriesAction).entries
|
||||||
|
case HistoryActionType.UPDATE_ENTRY:
|
||||||
|
return [
|
||||||
|
...state.filter(entry => entry.identifier !== (action as UpdateEntryAction).noteId),
|
||||||
|
(action as UpdateEntryAction).newEntry
|
||||||
|
]
|
||||||
|
case HistoryActionType.REMOVE_ENTRY:
|
||||||
|
return state.filter(entry => entry.identifier !== (action as RemoveEntryAction).noteId)
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
66
src/redux/history/types.ts
Normal file
66
src/redux/history/types.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Action } from 'redux'
|
||||||
|
|
||||||
|
export enum HistoryEntryOrigin {
|
||||||
|
LOCAL,
|
||||||
|
REMOTE
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
identifier: string
|
||||||
|
title: string
|
||||||
|
lastVisited: string
|
||||||
|
tags: string[]
|
||||||
|
pinStatus: boolean
|
||||||
|
origin: HistoryEntryOrigin
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface V1HistoryEntry {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
time: number
|
||||||
|
tags: string[]
|
||||||
|
pinned: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryExportJson {
|
||||||
|
version: number,
|
||||||
|
entries: HistoryEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum HistoryActionType {
|
||||||
|
SET_ENTRIES = 'SET_ENTRIES',
|
||||||
|
ADD_ENTRY = 'ADD_ENTRY',
|
||||||
|
UPDATE_ENTRY = 'UPDATE_ENTRY',
|
||||||
|
REMOVE_ENTRY = 'REMOVE_ENTRY'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryAction extends Action<HistoryActionType> {
|
||||||
|
type: HistoryActionType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetEntriesAction extends HistoryAction {
|
||||||
|
type: HistoryActionType.SET_ENTRIES
|
||||||
|
entries: HistoryEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddEntryAction extends HistoryAction {
|
||||||
|
type: HistoryActionType.ADD_ENTRY
|
||||||
|
newEntry: HistoryEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEntryAction extends HistoryAction {
|
||||||
|
type: HistoryActionType.UPDATE_ENTRY
|
||||||
|
noteId: string
|
||||||
|
newEntry: HistoryEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveEntryAction extends HistoryEntry {
|
||||||
|
type: HistoryActionType.REMOVE_ENTRY
|
||||||
|
noteId: string
|
||||||
|
}
|
|
@ -21,11 +21,14 @@ import { UserReducer } from './user/reducers'
|
||||||
import { MaybeUserState } from './user/types'
|
import { MaybeUserState } from './user/types'
|
||||||
import { UiNotificationState } from './ui-notifications/types'
|
import { UiNotificationState } from './ui-notifications/types'
|
||||||
import { UiNotificationReducer } from './ui-notifications/reducers'
|
import { UiNotificationReducer } from './ui-notifications/reducers'
|
||||||
|
import { HistoryEntry } from './history/types'
|
||||||
|
import { HistoryReducer } from './history/reducers'
|
||||||
|
|
||||||
export interface ApplicationState {
|
export interface ApplicationState {
|
||||||
user: MaybeUserState;
|
user: MaybeUserState;
|
||||||
config: Config;
|
config: Config;
|
||||||
banner: BannerState;
|
banner: BannerState;
|
||||||
|
history: HistoryEntry[];
|
||||||
apiUrl: ApiUrlObject;
|
apiUrl: ApiUrlObject;
|
||||||
editorConfig: EditorConfig;
|
editorConfig: EditorConfig;
|
||||||
darkMode: DarkModeConfig;
|
darkMode: DarkModeConfig;
|
||||||
|
@ -38,6 +41,7 @@ export const allReducers: Reducer<ApplicationState> = combineReducers<Applicatio
|
||||||
config: ConfigReducer,
|
config: ConfigReducer,
|
||||||
banner: BannerReducer,
|
banner: BannerReducer,
|
||||||
apiUrl: ApiUrlReducer,
|
apiUrl: ApiUrlReducer,
|
||||||
|
history: HistoryReducer,
|
||||||
editorConfig: EditorConfigReducer,
|
editorConfig: EditorConfigReducer,
|
||||||
darkMode: DarkModeConfigReducer,
|
darkMode: DarkModeConfigReducer,
|
||||||
noteDetails: NoteDetailsReducer,
|
noteDetails: NoteDetailsReducer,
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import i18n from 'i18next'
|
||||||
import { store } from '../index'
|
import { store } from '../index'
|
||||||
import {
|
import {
|
||||||
DismissUiNotificationAction,
|
DismissUiNotificationAction,
|
||||||
|
@ -37,3 +38,10 @@ export const dismissUiNotification = (notificationId: number): void => {
|
||||||
notificationId
|
notificationId
|
||||||
} as DismissUiNotificationAction)
|
} as DismissUiNotificationAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Promises catch errors as any.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types
|
||||||
|
export const showErrorNotification = (message: string) => (error: any): void => {
|
||||||
|
console.error(message, error)
|
||||||
|
dispatchUiNotification(i18n.t('common.errorOccurred'), message, DEFAULT_DURATION_IN_SECONDS, 'exclamation-triangle')
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es6",
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue