mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-04-08 21:00:58 +00: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()
|
||||
.as('pin-button')
|
||||
cy.get('@pin-button')
|
||||
.should('not.have.class', 'pinned')
|
||||
.should('have.class', 'pinned')
|
||||
.click()
|
||||
cy.get('@pin-button')
|
||||
.should('have.class', 'pinned')
|
||||
.should('not.have.class', 'pinned')
|
||||
})
|
||||
|
||||
it('Table', () => {
|
||||
|
@ -51,10 +51,10 @@ describe('History', () => {
|
|||
.first()
|
||||
.as('pin-button')
|
||||
cy.get('@pin-button')
|
||||
.should('not.have.class', 'pinned')
|
||||
.should('have.class', 'pinned')
|
||||
.click()
|
||||
cy.get('@pin-button')
|
||||
.should('have.class', 'pinned')
|
||||
.should('not.have.class', 'pinned')
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -71,7 +71,7 @@ describe('History', () => {
|
|||
cy.get('.fa-thumb-tack')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.modal-dialog')
|
||||
cy.get('.notifications-area .toast')
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
|
@ -81,7 +81,7 @@ describe('History', () => {
|
|||
cy.get('.fa-thumb-tack')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.modal-dialog')
|
||||
cy.get('.notifications-area .toast')
|
||||
.should('be.visible')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
[
|
||||
{
|
||||
"id": "29QLD0AmT-adevdOPECtqg",
|
||||
"identifier": "29QLD0AmT-adevdOPECtqg",
|
||||
"title": "HedgeDoc community call 2020-04-26",
|
||||
"lastVisited": "2020-05-16T22:26:56.547Z",
|
||||
"pinStatus": false,
|
||||
"tags": [
|
||||
"HedgeDoc",
|
||||
"Community Call"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "features",
|
||||
"identifier": "features",
|
||||
"title": "Features",
|
||||
"lastVisited": "2020-05-31T15:20:36.088Z",
|
||||
"pinStatus": true,
|
||||
"tags": [
|
||||
"features",
|
||||
"cool",
|
||||
|
@ -19,15 +21,17 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"id": "ODakLc2MQkyyFc_Xmb53sg",
|
||||
"identifier": "ODakLc2MQkyyFc_Xmb53sg",
|
||||
"title": "HedgeDoc V2 API",
|
||||
"lastVisited": "2020-05-25T19:48:14.025Z",
|
||||
"pinStatus": false,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"id": "l8JuWxApTR6Fqa0LCrpnLg",
|
||||
"identifier": "l8JuWxApTR6Fqa0LCrpnLg",
|
||||
"title": "Community call - Let’s meet! (2020-06-06 18:00 UTC / 20:00 CEST)",
|
||||
"lastVisited": "2020-05-24T16:04:36.433Z",
|
||||
"pinStatus": false,
|
||||
"tags": [
|
||||
"agenda",
|
||||
"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
|
||||
*/
|
||||
|
||||
import { HistoryEntry } from '../../components/history-page/history-page'
|
||||
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')
|
||||
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', {
|
||||
...defaultFetchConfig,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
history: entries
|
||||
})
|
||||
body: JSON.stringify(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)
|
||||
}
|
||||
|
@ -31,21 +46,3 @@ export const deleteHistory = async (): Promise<void> => {
|
|||
})
|
||||
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 { setUpI18n } from './i18n'
|
||||
import { refreshHistoryState } from '../../../redux/history/methods'
|
||||
|
||||
const customDelay: () => Promise<void> = async () => {
|
||||
if (window.localStorage.getItem('customDelay')) {
|
||||
|
@ -27,6 +28,9 @@ export const createSetUpTaskList = (baseUrl: string): InitTask[] => {
|
|||
}, {
|
||||
name: 'Load config',
|
||||
task: loadAllConfig(baseUrl)
|
||||
}, {
|
||||
name: 'Load history state',
|
||||
task: refreshHistoryState()
|
||||
}, {
|
||||
name: 'Add Delay',
|
||||
task: customDelay()
|
||||
|
|
|
@ -9,24 +9,28 @@ import { Dropdown } from 'react-bootstrap'
|
|||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||
import { ShowIf } from '../../common/show-if/show-if'
|
||||
import { HistoryEntryOrigin } from '../history-page'
|
||||
import { DeleteNoteItem } from './delete-note-item'
|
||||
import './entry-menu.scss'
|
||||
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 {
|
||||
id: string;
|
||||
title: string
|
||||
location: HistoryEntryOrigin
|
||||
origin: HistoryEntryOrigin
|
||||
isDark: boolean;
|
||||
onRemove: () => void
|
||||
onDelete: () => void
|
||||
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()
|
||||
|
||||
const userExists = useSelector((state: ApplicationState) => !!state.user)
|
||||
|
||||
return (
|
||||
<Dropdown className={ `d-inline-flex ${ className || '' }` }>
|
||||
<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"/>
|
||||
</Dropdown.Header>
|
||||
|
||||
<ShowIf condition={ location === HistoryEntryOrigin.LOCAL }>
|
||||
<ShowIf condition={ origin === HistoryEntryOrigin.LOCAL }>
|
||||
<Dropdown.Item disabled>
|
||||
<ForkAwesomeIcon icon="laptop" fixedWidth={ true } className="mx-2"/>
|
||||
<Trans i18nKey="landing.history.menu.entryLocal"/>
|
||||
</Dropdown.Item>
|
||||
</ShowIf>
|
||||
<ShowIf condition={ location === HistoryEntryOrigin.REMOTE }>
|
||||
<ShowIf condition={ origin === HistoryEntryOrigin.REMOTE }>
|
||||
<Dropdown.Item disabled>
|
||||
<ForkAwesomeIcon icon="cloud" fixedWidth={ true } className="mx-2"/>
|
||||
<Trans i18nKey="landing.history.menu.entryRemote"/>
|
||||
|
@ -54,9 +58,10 @@ export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, location, isDar
|
|||
</ShowIf>
|
||||
<RemoveNoteEntryItem onConfirm={ onRemove } noteTitle={ title }/>
|
||||
|
||||
<Dropdown.Divider/>
|
||||
|
||||
<DeleteNoteItem onConfirm={ onDelete } noteTitle={ title }/>
|
||||
<ShowIf condition={ userExists }>
|
||||
<Dropdown.Divider/>
|
||||
<DeleteNoteItem onConfirm={ onDelete } noteTitle={ title }/>
|
||||
</ShowIf>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
|
|
|
@ -7,17 +7,17 @@
|
|||
import React from 'react'
|
||||
import { Row } from 'react-bootstrap'
|
||||
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'
|
||||
|
||||
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 (
|
||||
<Row className="justify-content-start">
|
||||
<Pager numberOfElementsPerPage={ 9 } pageIndex={ pageIndex } onLastPageIndexChange={ onLastPageIndexChange }>
|
||||
{
|
||||
entries.map((entry) => (
|
||||
<HistoryCard
|
||||
key={ entry.id }
|
||||
key={ entry.identifier }
|
||||
entry={ entry }
|
||||
onPinClick={ onPinClick }
|
||||
onRemoveClick={ onRemoveClick }
|
||||
|
|
|
@ -5,26 +5,34 @@
|
|||
*/
|
||||
|
||||
import { DateTime } from 'luxon'
|
||||
import React from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Badge, Card } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||
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 { formatHistoryDate } from '../utils'
|
||||
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 (
|
||||
<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.Body className="p-2 d-flex flex-row justify-content-between">
|
||||
<div className={ 'd-flex flex-column' }>
|
||||
<PinButton isDark={ false } isPinned={ entry.pinned }
|
||||
onPinClick={ () => onPinClick(entry.id, entry.location) }/>
|
||||
<PinButton isDark={ false } isPinned={ entry.pinStatus }
|
||||
onPinClick={ () => onPinClick(entry.identifier) }/>
|
||||
</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' }>
|
||||
<Card.Title className="m-0 mt-1dot5">{ entry.title }</Card.Title>
|
||||
<div>
|
||||
|
@ -44,12 +52,12 @@ export const HistoryCard: React.FC<HistoryEntryProps> = ({ entry, onPinClick, on
|
|||
</Link>
|
||||
<div className={ 'd-flex flex-column' }>
|
||||
<EntryMenu
|
||||
id={ entry.id }
|
||||
id={ entry.identifier }
|
||||
title={ entry.title }
|
||||
location={ entry.location }
|
||||
origin={ entry.origin }
|
||||
isDark={ false }
|
||||
onRemove={ () => onRemoveClick(entry.id, entry.location) }
|
||||
onDelete={ () => onDeleteClick(entry.id, entry.location) }
|
||||
onRemove={ onRemove }
|
||||
onDelete={ onDelete }
|
||||
/>
|
||||
</div>
|
||||
</Card.Body>
|
||||
|
|
|
@ -4,46 +4,67 @@
|
|||
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 { Trans, useTranslation } from 'react-i18next'
|
||||
import { PagerPagination } from '../../common/pagination/pager-pagination'
|
||||
import { HistoryCardList } from '../history-card/history-card-list'
|
||||
import { HistoryEntryOrigin, LocatedHistoryEntry } from '../history-page'
|
||||
import { HistoryTable } from '../history-table/history-table'
|
||||
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 {
|
||||
viewState: ViewStateEnum
|
||||
entries: LocatedHistoryEntry[]
|
||||
onPinClick: OnEntryClick
|
||||
onRemoveClick: OnEntryClick
|
||||
onDeleteClick: OnEntryClick
|
||||
entries: HistoryEntry[]
|
||||
}
|
||||
|
||||
export interface HistoryEntryProps {
|
||||
entry: LocatedHistoryEntry,
|
||||
onPinClick: OnEntryClick
|
||||
onRemoveClick: OnEntryClick
|
||||
onDeleteClick: OnEntryClick
|
||||
entry: HistoryEntry,
|
||||
}
|
||||
|
||||
export interface HistoryEntriesProps {
|
||||
entries: LocatedHistoryEntry[]
|
||||
onPinClick: OnEntryClick
|
||||
onRemoveClick: OnEntryClick
|
||||
onDeleteClick: OnEntryClick
|
||||
entries: HistoryEntry[]
|
||||
pageIndex: number
|
||||
onLastPageIndexChange: (lastPageIndex: number) => void
|
||||
}
|
||||
|
||||
export const HistoryContent: React.FC<HistoryContentProps> = ({ viewState, entries, onPinClick, onRemoveClick, onDeleteClick }) => {
|
||||
useTranslation()
|
||||
export const HistoryContent: React.FC<HistoryContentProps> = ({ viewState, entries }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [pageIndex, setPageIndex] = 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) {
|
||||
return (
|
||||
<Row className={ 'justify-content-center' }>
|
||||
|
|
|
@ -4,226 +4,48 @@
|
|||
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 { Trans, useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { deleteHistory, deleteHistoryEntry, getHistory, setHistory, updateHistoryEntry } from '../../api/history'
|
||||
import { deleteNote } from '../../api/notes'
|
||||
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 { HistoryToolbar, HistoryToolbarState, initState as toolbarInitState } from './history-toolbar/history-toolbar'
|
||||
|
||||
import {
|
||||
collectEntries,
|
||||
loadHistoryFromLocalStore,
|
||||
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'
|
||||
}
|
||||
import { sortAndFilterEntries } from './utils'
|
||||
import { refreshHistoryState } from '../../redux/history/methods'
|
||||
import { HistoryEntry } from '../../redux/history/types'
|
||||
import { showErrorNotification } from '../../redux/ui-notifications/methods'
|
||||
|
||||
export const HistoryPage: React.FC = () => {
|
||||
useTranslation()
|
||||
const [localHistoryEntries, setLocalHistoryEntries] = useState<HistoryEntry[]>(loadHistoryFromLocalStore)
|
||||
const [remoteHistoryEntries, setRemoteHistoryEntries] = useState<HistoryEntry[]>([])
|
||||
const { t } = useTranslation()
|
||||
|
||||
const allEntries = useSelector((state: ApplicationState) => state.history)
|
||||
const [toolbarState, setToolbarState] = useState<HistoryToolbarState>(toolbarInitState)
|
||||
const userExists = useSelector((state: ApplicationState) => !!state.user)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const historyWrite = useCallback((entries: 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[]>(() =>
|
||||
const entriesToShow = useMemo<HistoryEntry[]>(() =>
|
||||
sortAndFilterEntries(allEntries, toolbarState),
|
||||
[allEntries, toolbarState])
|
||||
|
||||
return <Fragment>
|
||||
<ErrorModal show={ error !== '' } onHide={ resetError }
|
||||
titleI18nKey={ error !== '' ? `landing.history.error.${ error }.title` : '' }>
|
||||
<h5>
|
||||
<Trans i18nKey={ error !== '' ? `landing.history.error.${ error }.text` : '' }/>
|
||||
</h5>
|
||||
</ErrorModal>
|
||||
<h1 className="mb-4"><Trans i18nKey="landing.navigation.history"/></h1>
|
||||
<Row className={ 'justify-content-center mt-5 mb-3' }>
|
||||
<HistoryToolbar
|
||||
onSettingsChange={ setToolbarState }
|
||||
tags={ tags }
|
||||
onClearHistory={ clearHistory }
|
||||
onRefreshHistory={ refreshHistory }
|
||||
onExportHistory={ exportHistory }
|
||||
onImportHistory={ importHistory }
|
||||
onUploadAll={ uploadAll }
|
||||
useEffect(() => {
|
||||
refreshHistoryState().catch(
|
||||
showErrorNotification(t('landing.history.error.getHistory.text'))
|
||||
)
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<h1 className="mb-4">
|
||||
<Trans i18nKey="landing.navigation.history"/>
|
||||
</h1>
|
||||
<Row className={ 'justify-content-center mt-5 mb-3' }>
|
||||
<HistoryToolbar
|
||||
onSettingsChange={ setToolbarState }
|
||||
/>
|
||||
</Row>
|
||||
<HistoryContent
|
||||
viewState={ toolbarState.viewState }
|
||||
entries={ entriesToShow }
|
||||
/>
|
||||
</Row>
|
||||
<HistoryContent
|
||||
viewState={ toolbarState.viewState }
|
||||
entries={ entriesToShow }
|
||||
onPinClick={ pinClick }
|
||||
onRemoveClick={ removeFromHistoryClick }
|
||||
onDeleteClick={ deleteNoteClick }
|
||||
/>
|
||||
</Fragment>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,15 +8,15 @@ import React from 'react'
|
|||
import { Badge } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
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 { 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 (
|
||||
<tr>
|
||||
<td>
|
||||
<Link to={ `/n/${ entry.id }` } className="text-light">
|
||||
<Link to={ `/n/${ entry.identifier }` } className="text-light">
|
||||
{ entry.title }
|
||||
</Link>
|
||||
</td>
|
||||
|
@ -28,15 +28,15 @@ export const HistoryTableRow: React.FC<HistoryEntryProps> = ({ entry, onPinClick
|
|||
}
|
||||
</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' }/>
|
||||
<EntryMenu
|
||||
id={ entry.id }
|
||||
id={ entry.identifier }
|
||||
title={ entry.title }
|
||||
location={ entry.location }
|
||||
origin={ entry.origin }
|
||||
isDark={ true }
|
||||
onRemove={ () => onRemoveClick(entry.id, entry.location) }
|
||||
onDelete={ () => onDeleteClick(entry.id, entry.location) }
|
||||
onRemove={ () => onRemoveClick(entry.identifier) }
|
||||
onDelete={ () => onDeleteClick(entry.identifier) }
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -8,11 +8,11 @@ import React from 'react'
|
|||
import { Table } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
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 './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()
|
||||
return (
|
||||
<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) =>
|
||||
<HistoryTableRow
|
||||
key={ entry.id }
|
||||
key={ entry.identifier }
|
||||
entry={ entry }
|
||||
onPinClick={ onPinClick }
|
||||
onRemoveClick={ onRemoveClick }
|
||||
|
|
|
@ -4,33 +4,38 @@
|
|||
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 { Trans, useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||
import { DeletionModal } from '../../common/modals/deletion-modal'
|
||||
import { deleteAllHistoryEntries, refreshHistoryState } from '../../../redux/history/methods'
|
||||
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||
|
||||
export interface ClearHistoryButtonProps {
|
||||
onClearHistory: () => void
|
||||
}
|
||||
|
||||
export const ClearHistoryButton: React.FC<ClearHistoryButtonProps> = ({ onClearHistory }) => {
|
||||
export const ClearHistoryButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const handleShow = () => setShow(true)
|
||||
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 (
|
||||
<Fragment>
|
||||
<Button variant={ 'light' } title={ t('landing.history.toolbar.clear') } onClick={ handleShow }>
|
||||
<ForkAwesomeIcon icon={ 'trash' }/>
|
||||
</Button>
|
||||
<DeletionModal
|
||||
onConfirm={ () => {
|
||||
onClearHistory()
|
||||
handleClose()
|
||||
} }
|
||||
onConfirm={ onConfirm }
|
||||
deletionButtonI18nKey={ 'landing.history.toolbar.clear' }
|
||||
show={ show }
|
||||
onHide={ handleClose }
|
||||
|
|
|
@ -8,16 +8,13 @@ import React from 'react'
|
|||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||
import { downloadHistory } from '../../../redux/history/methods'
|
||||
|
||||
export interface ExportHistoryButtonProps {
|
||||
onExportHistory: () => void
|
||||
}
|
||||
|
||||
export const ExportHistoryButton: React.FC<ExportHistoryButtonProps> = ({ onExportHistory }) => {
|
||||
export const ExportHistoryButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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'/>
|
||||
</Button>
|
||||
)
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
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 { Typeahead } from 'react-bootstrap-typeahead'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
@ -12,12 +12,14 @@ import { useSelector } from 'react-redux'
|
|||
import { ApplicationState } from '../../../redux'
|
||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||
import { ShowIf } from '../../common/show-if/show-if'
|
||||
import { HistoryEntry } from '../history-page'
|
||||
import { SortButton, SortModeEnum } from '../sort-button/sort-button'
|
||||
import { ClearHistoryButton } from './clear-history-button'
|
||||
import { ExportHistoryButton } from './export-history-button'
|
||||
import { ImportHistoryButton } from './import-history-button'
|
||||
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;
|
||||
|
||||
|
@ -36,12 +38,6 @@ export enum ViewStateEnum {
|
|||
|
||||
export interface HistoryToolbarProps {
|
||||
onSettingsChange: HistoryToolbarChange
|
||||
tags: string[]
|
||||
onClearHistory: () => void
|
||||
onRefreshHistory: () => void
|
||||
onExportHistory: () => void
|
||||
onImportHistory: (entries: HistoryEntry[]) => void
|
||||
onUploadAll: () => void
|
||||
}
|
||||
|
||||
export const initState: HistoryToolbarState = {
|
||||
|
@ -52,11 +48,18 @@ export const initState: HistoryToolbarState = {
|
|||
selectedTags: []
|
||||
}
|
||||
|
||||
export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange, tags, onClearHistory, onRefreshHistory, onExportHistory, onImportHistory, onUploadAll }) => {
|
||||
const [t] = useTranslation()
|
||||
export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange }) => {
|
||||
const { t } = useTranslation()
|
||||
const [state, setState] = useState<HistoryToolbarState>(initState)
|
||||
const historyEntries = useSelector((state: ApplicationState) => state.history)
|
||||
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) => {
|
||||
setState(prevState => ({
|
||||
...prevState,
|
||||
|
@ -85,6 +88,33 @@ export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange
|
|||
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(() => {
|
||||
onSettingsChange(state)
|
||||
}, [onSettingsChange, state])
|
||||
|
@ -113,28 +143,28 @@ export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange
|
|||
variant={ 'light' }><Trans i18nKey={ 'landing.history.toolbar.sortByLastVisited' }/></SortButton>
|
||||
</InputGroup>
|
||||
<InputGroup className={ 'mr-1 mb-1' }>
|
||||
<ExportHistoryButton onExportHistory={ onExportHistory }/>
|
||||
<ExportHistoryButton/>
|
||||
</InputGroup>
|
||||
<InputGroup className={ 'mr-1 mb-1' }>
|
||||
<ImportHistoryButton onImportHistory={ onImportHistory }/>
|
||||
<ImportHistoryButton/>
|
||||
</InputGroup>
|
||||
<InputGroup className={ 'mr-1 mb-1' }>
|
||||
<ClearHistoryButton onClearHistory={ onClearHistory }/>
|
||||
<ClearHistoryButton/>
|
||||
</InputGroup>
|
||||
<InputGroup className={ 'mr-1 mb-1' }>
|
||||
<Button variant={ 'light' } title={ t('landing.history.toolbar.refresh') } onClick={ onRefreshHistory }>
|
||||
<ForkAwesomeIcon icon='refresh'/>
|
||||
<Button variant={ 'light' } title={ t('landing.history.toolbar.refresh') } onClick={ refreshHistory }>
|
||||
<ForkAwesomeIcon icon="refresh"/>
|
||||
</Button>
|
||||
</InputGroup>
|
||||
<ShowIf condition={ userExists }>
|
||||
<InputGroup className={ 'mr-1 mb-1' }>
|
||||
<Button variant={ 'light' } title={ t('landing.history.toolbar.uploadAll') } onClick={ onUploadAll }>
|
||||
<ForkAwesomeIcon icon='cloud-upload'/>
|
||||
<Button variant={ 'light' } title={ t('landing.history.toolbar.uploadAll') } onClick={ onUploadAllToRemote }>
|
||||
<ForkAwesomeIcon icon="cloud-upload"/>
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</ShowIf>
|
||||
<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) => {
|
||||
toggleViewChanged(newViewState)
|
||||
} }>
|
||||
|
|
|
@ -4,34 +4,50 @@
|
|||
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 { Trans, useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||
import { ErrorModal } from '../../common/modals/error-modal'
|
||||
import { HistoryEntry, HistoryJson } from '../history-page'
|
||||
import { convertV1History, V1HistoryEntry } from '../utils'
|
||||
import { HistoryEntry, HistoryEntryOrigin, HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
|
||||
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 {
|
||||
onImportHistory: (entries: HistoryEntry[]) => void
|
||||
}
|
||||
|
||||
export const ImportHistoryButton: React.FC<ImportHistoryButtonProps> = ({ onImportHistory }) => {
|
||||
export const ImportHistoryButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const userExists = useSelector((state: ApplicationState) => !!state.user)
|
||||
const historyState = useSelector((state: ApplicationState) => state.history)
|
||||
const uploadInput = useRef<HTMLInputElement>(null)
|
||||
const [show, setShow] = useState(false)
|
||||
const [fileName, setFilename] = useState('')
|
||||
const [i18nKey, setI18nKey] = useState('')
|
||||
|
||||
const handleShow = (key: string) => {
|
||||
const handleShow = useCallback((key: string) => {
|
||||
setI18nKey(key)
|
||||
setShow(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
const handleClose = useCallback(() => {
|
||||
setI18nKey('')
|
||||
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 { validity, files } = event.target
|
||||
|
@ -47,7 +63,7 @@ export const ImportHistoryButton: React.FC<ImportHistoryButtonProps> = ({ onImpo
|
|||
if (event.target && event.target.result) {
|
||||
try {
|
||||
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.version) {
|
||||
if (data.version === 2) {
|
||||
|
|
|
@ -6,47 +6,25 @@
|
|||
|
||||
import { DateTime } from 'luxon'
|
||||
import { SortModeEnum } from './sort-button/sort-button'
|
||||
import { HistoryEntry, HistoryEntryOrigin, LocatedHistoryEntry } from './history-page'
|
||||
import { HistoryToolbarState } from './history-toolbar/history-toolbar'
|
||||
import { HistoryEntry } from '../../redux/history/types'
|
||||
|
||||
export function collectEntries(localEntries: HistoryEntry[], remoteEntries: HistoryEntry[]): LocatedHistoryEntry[] {
|
||||
const locatedLocalEntries = locateEntries(localEntries, HistoryEntryOrigin.LOCAL)
|
||||
const locatedRemoteEntries = locateEntries(remoteEntries, HistoryEntryOrigin.REMOTE)
|
||||
return mergeEntryArrays(locatedLocalEntries, locatedRemoteEntries)
|
||||
}
|
||||
export const formatHistoryDate = (date: string): string => DateTime.fromISO(date).toFormat('DDDD T')
|
||||
|
||||
export function sortAndFilterEntries(entries: LocatedHistoryEntry[], toolbarState: HistoryToolbarState): LocatedHistoryEntry[] {
|
||||
export const sortAndFilterEntries = (entries: HistoryEntry[], toolbarState: HistoryToolbarState): HistoryEntry[] => {
|
||||
const filteredBySelectedTagsEntries = filterBySelectedTags(entries, toolbarState.selectedTags)
|
||||
const filteredByKeywordSearchEntries = filterByKeywordSearch(filteredBySelectedTagsEntries, toolbarState.keywordSearch)
|
||||
return sortEntries(filteredByKeywordSearchEntries, toolbarState)
|
||||
}
|
||||
|
||||
function locateEntries(entries: HistoryEntry[], location: HistoryEntryOrigin): LocatedHistoryEntry[] {
|
||||
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[] {
|
||||
const filterBySelectedTags = (entries: HistoryEntry[], selectedTags: string[]): HistoryEntry[] => {
|
||||
return entries.filter(entry => {
|
||||
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) =>
|
||||
array2.find((element2) =>
|
||||
element2 === element1
|
||||
|
@ -55,18 +33,21 @@ function arrayCommonCheck<T>(array1: T[], array2: T[]): boolean {
|
|||
return !!foundElement
|
||||
}
|
||||
|
||||
function filterByKeywordSearch(entries: LocatedHistoryEntry[], keywords: string): LocatedHistoryEntry[] {
|
||||
const filterByKeywordSearch = (entries: HistoryEntry[], keywords: string): HistoryEntry[] => {
|
||||
const searchTerm = keywords.toLowerCase()
|
||||
return entries.filter(entry => entry.title.toLowerCase()
|
||||
.includes(searchTerm))
|
||||
return entries.filter(
|
||||
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) => {
|
||||
if (firstEntry.pinned && !secondEntry.pinned) {
|
||||
if (firstEntry.pinStatus && !secondEntry.pinStatus) {
|
||||
return -1
|
||||
}
|
||||
if (!firstEntry.pinned && secondEntry.pinned) {
|
||||
if (!firstEntry.pinStatus && secondEntry.pinStatus) {
|
||||
return 1
|
||||
}
|
||||
|
||||
|
@ -86,47 +67,3 @@ function sortEntries(entries: LocatedHistoryEntry[], viewState: HistoryToolbarSt
|
|||
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 { UiNotificationState } from './ui-notifications/types'
|
||||
import { UiNotificationReducer } from './ui-notifications/reducers'
|
||||
import { HistoryEntry } from './history/types'
|
||||
import { HistoryReducer } from './history/reducers'
|
||||
|
||||
export interface ApplicationState {
|
||||
user: MaybeUserState;
|
||||
config: Config;
|
||||
banner: BannerState;
|
||||
history: HistoryEntry[];
|
||||
apiUrl: ApiUrlObject;
|
||||
editorConfig: EditorConfig;
|
||||
darkMode: DarkModeConfig;
|
||||
|
@ -38,6 +41,7 @@ export const allReducers: Reducer<ApplicationState> = combineReducers<Applicatio
|
|||
config: ConfigReducer,
|
||||
banner: BannerReducer,
|
||||
apiUrl: ApiUrlReducer,
|
||||
history: HistoryReducer,
|
||||
editorConfig: EditorConfigReducer,
|
||||
darkMode: DarkModeConfigReducer,
|
||||
noteDetails: NoteDetailsReducer,
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import i18n from 'i18next'
|
||||
import { store } from '../index'
|
||||
import {
|
||||
DismissUiNotificationAction,
|
||||
|
@ -37,3 +38,10 @@ export const dismissUiNotification = (notificationId: number): void => {
|
|||
notificationId
|
||||
} 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": {
|
||||
"target": "es5",
|
||||
"target": "es6",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
|
|
Loading…
Add table
Reference in a new issue