mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-18 09:04:44 -04:00
refactor: remove history page
This needs to be done since the backend does not include code for the history page anymore. This will be replaced with the explore page in the near future anyway. Co-authored-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
c0ce00b3f9
commit
d67e44f540
75 changed files with 76 additions and 2727 deletions
|
@ -1,206 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntry } from '../../src/api/history/types'
|
||||
|
||||
describe('History', () => {
|
||||
describe('History Mode', () => {
|
||||
beforeEach(() => {
|
||||
cy.visitHistory()
|
||||
})
|
||||
|
||||
it('Cards', () => {
|
||||
cy.getByCypressId('history-card').should('be.visible')
|
||||
})
|
||||
|
||||
it('Table', () => {
|
||||
cy.getByCypressId('history-mode-table').click()
|
||||
cy.getByCypressId('history-table').should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
describe('entry title', () => {
|
||||
describe('is as given when not empty', () => {
|
||||
beforeEach(() => {
|
||||
cy.clearLocalStorage('history')
|
||||
cy.intercept('GET', '/api/private/me/history', {
|
||||
body: [
|
||||
{
|
||||
identifier: 'cypress',
|
||||
title: 'Features',
|
||||
lastVisitedAt: '2020-05-16T22:26:56.547Z',
|
||||
pinStatus: false,
|
||||
tags: []
|
||||
} as HistoryEntry
|
||||
]
|
||||
})
|
||||
cy.visitHistory()
|
||||
})
|
||||
|
||||
it('in table view', () => {
|
||||
cy.getByCypressId('history-mode-table').click()
|
||||
cy.getByCypressId('history-table').should('be.visible')
|
||||
cy.getByCypressId('history-entry-title').contains('Features')
|
||||
})
|
||||
|
||||
it('in cards view', () => {
|
||||
cy.getByCypressId('history-entry-title').contains('Features')
|
||||
})
|
||||
})
|
||||
describe('is untitled when not empty', () => {
|
||||
beforeEach(() => {
|
||||
cy.clearLocalStorage('history')
|
||||
cy.intercept('GET', '/api/private/me/history', {
|
||||
body: [
|
||||
{
|
||||
identifier: 'cypress-no-title',
|
||||
title: '',
|
||||
lastVisitedAt: '2020-05-16T22:26:56.547Z',
|
||||
pinStatus: false,
|
||||
tags: []
|
||||
} as HistoryEntry
|
||||
]
|
||||
})
|
||||
cy.visitHistory()
|
||||
})
|
||||
|
||||
it('in table view', () => {
|
||||
cy.getByCypressId('history-mode-table').click()
|
||||
cy.getByCypressId('history-table').should('be.visible')
|
||||
cy.getByCypressId('history-entry-title').contains('Untitled')
|
||||
})
|
||||
|
||||
it('in cards view', () => {
|
||||
cy.getByCypressId('history-entry-title').contains('Untitled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pinning', () => {
|
||||
beforeEach(() => {
|
||||
cy.visitHistory()
|
||||
})
|
||||
|
||||
describe('working', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('PUT', '/api/private/me/history/features', (req) => {
|
||||
req.reply(200, req.body)
|
||||
})
|
||||
})
|
||||
|
||||
it('Cards', () => {
|
||||
cy.getByCypressId('history-card').should('be.visible')
|
||||
cy.get('[data-cypress-card-title=Features]')
|
||||
.findByCypressId('history-entry-pin-button')
|
||||
.should('have.attr', 'data-cypress-pinned', 'true')
|
||||
.click()
|
||||
cy.get('[data-cypress-card-title=Features]')
|
||||
.findByCypressId('history-entry-pin-button')
|
||||
.should('have.attr', 'data-cypress-pinned', 'false')
|
||||
})
|
||||
|
||||
it('Table', () => {
|
||||
cy.getByCypressId('history-mode-table').click()
|
||||
cy.get('[data-cypress-entry-title=Features]')
|
||||
.findByCypressId('history-entry-pin-button')
|
||||
.should('have.attr', 'data-cypress-pinned', 'true')
|
||||
.click()
|
||||
cy.get('[data-cypress-entry-title=Features]')
|
||||
.findByCypressId('history-entry-pin-button')
|
||||
.should('have.attr', 'data-cypress-pinned', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('failing', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('PUT', '/api/private/me/history/features', {
|
||||
statusCode: 401
|
||||
})
|
||||
})
|
||||
|
||||
it('Cards', () => {
|
||||
cy.getByCypressId('history-card').should('be.visible')
|
||||
cy.get('[data-cypress-card-title=Features]')
|
||||
.findByCypressId('history-entry-pin-button')
|
||||
.should('have.attr', 'data-cypress-pinned', 'true')
|
||||
.click()
|
||||
cy.getByCypressId('notification-toast').should('be.visible')
|
||||
})
|
||||
|
||||
it('Table', () => {
|
||||
cy.getByCypressId('history-mode-table').click()
|
||||
cy.get('[data-cypress-entry-title=Features]')
|
||||
.findByCypressId('history-entry-pin-button')
|
||||
.should('have.attr', 'data-cypress-pinned', 'true')
|
||||
.click()
|
||||
cy.getByCypressId('notification-toast').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import', () => {
|
||||
beforeEach(() => {
|
||||
cy.clearLocalStorage('history')
|
||||
cy.intercept('GET', '/api/private/me/history', {
|
||||
body: []
|
||||
})
|
||||
cy.visitHistory()
|
||||
cy.logOut()
|
||||
|
||||
cy.fixture('history.json').as('history')
|
||||
cy.fixture('history-2.json').as('history-2')
|
||||
cy.fixture('invalid-history.txt').as('invalid-history')
|
||||
})
|
||||
|
||||
it('works with valid file', () => {
|
||||
cy.getByCypressId('import-history-file-button').should('be.visible')
|
||||
cy.getByCypressId('import-history-file-input').selectFile(
|
||||
{
|
||||
contents: '@history',
|
||||
fileName: 'history.json',
|
||||
mimeType: 'application/json'
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
cy.getByCypressId('history-entry-title').should('have.length', 1).contains('cy-Test')
|
||||
})
|
||||
|
||||
it('fails on invalid file', () => {
|
||||
cy.getByCypressId('import-history-file-button').should('be.visible')
|
||||
cy.getByCypressId('import-history-file-input').selectFile(
|
||||
{
|
||||
contents: '@invalid-history',
|
||||
fileName: 'invalid-history.txt',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
cy.getByCypressId('notification-toast').should('be.visible')
|
||||
})
|
||||
|
||||
it('works when selecting two files with the same name', () => {
|
||||
cy.getByCypressId('import-history-file-button').should('be.visible')
|
||||
cy.getByCypressId('import-history-file-input').selectFile(
|
||||
{
|
||||
contents: '@history',
|
||||
fileName: 'history.json',
|
||||
mimeType: 'application/json'
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
cy.getByCypressId('history-entry-title').should('have.length', 1).contains('cy-Test')
|
||||
cy.getByCypressId('import-history-file-button').should('be.visible')
|
||||
cy.getByCypressId('import-history-file-input').selectFile(
|
||||
{
|
||||
contents: '@history-2',
|
||||
fileName: 'history.json',
|
||||
mimeType: 'application/json'
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
cy.getByCypressId('history-entry-title').should('have.length', 2).contains('cy-Test2')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -19,7 +19,7 @@ describe('Revision modal', () => {
|
|||
createdAt: defaultCreatedAt,
|
||||
length: 2788,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 4,
|
||||
guestAuthorUuids: ['1', '2', '3', '4'],
|
||||
title: 'Features',
|
||||
description: 'Many features, such wow!',
|
||||
tags: ['hedgedoc', 'demo', 'react']
|
||||
|
@ -29,7 +29,7 @@ describe('Revision modal', () => {
|
|||
createdAt: defaultCreatedAt,
|
||||
length: 2782,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 2,
|
||||
guestAuthorUuids: ['1', '2'],
|
||||
title: 'Features',
|
||||
description: 'Many more features, such wow!',
|
||||
tags: ['hedgedoc', 'demo', 'react']
|
||||
|
@ -81,7 +81,7 @@ describe('Revision modal', () => {
|
|||
edits: [],
|
||||
length: 2788,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 4,
|
||||
authorGuestUuids: ['1', '2', '3'],
|
||||
content: testContent
|
||||
})
|
||||
|
||||
|
|
|
@ -8,20 +8,13 @@ import type { NoteDto } from '@hedgedoc/commons'
|
|||
export const testNoteId = 'test'
|
||||
const mockMetadata = {
|
||||
id: testNoteId,
|
||||
aliases: [
|
||||
{
|
||||
name: 'mock-note',
|
||||
primaryAlias: true,
|
||||
noteId: testNoteId
|
||||
}
|
||||
],
|
||||
aliases: ['mock-note'],
|
||||
primaryAlias: 'mock-note',
|
||||
title: 'Mock Note',
|
||||
description: 'Mocked note for testing',
|
||||
tags: ['test', 'mock', 'cypress'],
|
||||
updatedAt: '2021-04-24T09:27:51.000Z',
|
||||
lastUpdatedBy: null,
|
||||
viewCount: 0,
|
||||
version: 2,
|
||||
createdAt: '2021-04-24T09:27:51.000Z',
|
||||
editedBy: [],
|
||||
|
|
|
@ -19,10 +19,6 @@ Cypress.Commands.add('visitHome', () => {
|
|||
return cy.visit('/', { retryOnNetworkFailure: true, retryOnStatusCodeFailure: true })
|
||||
})
|
||||
|
||||
Cypress.Commands.add('visitHistory', () => {
|
||||
return cy.visit(`/history`, { retryOnNetworkFailure: true, retryOnStatusCodeFailure: true })
|
||||
})
|
||||
|
||||
export enum PAGE_MODE {
|
||||
EDITOR = 'n',
|
||||
PRESENTATION = 'p',
|
||||
|
|
|
@ -59,88 +59,8 @@
|
|||
"markdownWhileLoading": "Loading...",
|
||||
"markdownLoadingError": "Error while fetching intro content"
|
||||
},
|
||||
"history": {
|
||||
"error": {
|
||||
"getHistory": {
|
||||
"title": "Load History Error",
|
||||
"text": "While trying to load the history form the server an error occurred"
|
||||
},
|
||||
"deleteHistory": {
|
||||
"title": "Delete History Error",
|
||||
"text": "While trying to delete the history on the server an error occurred"
|
||||
},
|
||||
"setHistory": {
|
||||
"title": "Upload History Error",
|
||||
"text": "While trying to upload the history to the server an error occurred"
|
||||
},
|
||||
"deleteNote": {
|
||||
"title": "Delete Note Error",
|
||||
"text": "While trying to delete a note on the server an error occurred"
|
||||
},
|
||||
"updateEntry": {
|
||||
"title": "Update History Entry Error",
|
||||
"text": "While trying to update a history entry on the server an error occurred"
|
||||
},
|
||||
"deleteEntry": {
|
||||
"title": "Delete History Entry Error",
|
||||
"text": "While trying to delete a history entry on the server an error occurred"
|
||||
},
|
||||
"notFoundEntry": {
|
||||
"title": "History Entry not found",
|
||||
"text": "We can't find the history entry you requested."
|
||||
}
|
||||
},
|
||||
"noHistory": "No history",
|
||||
"localHistory": "Below is history from this browser",
|
||||
"toolbar": {
|
||||
"cards": "Cards",
|
||||
"table": "Table",
|
||||
"selectTags": "Select tags…",
|
||||
"searchKeywords": "Search keyword…",
|
||||
"sortByTitle": "Sort by title",
|
||||
"sortByLastVisited": "Sort by time",
|
||||
"export": "Export history",
|
||||
"import": "Import history",
|
||||
"clear": "Clear history",
|
||||
"refresh": "Refresh history",
|
||||
"uploadAll": "Sync the complete history to the server"
|
||||
},
|
||||
"modal": {
|
||||
"clearHistory": {
|
||||
"title": "Delete history",
|
||||
"question": "Do you want to clear the history?",
|
||||
"disclaimer": "This won't delete any notes."
|
||||
},
|
||||
"importHistoryError": {
|
||||
"title": "An error occurred",
|
||||
"textWithFile": "While trying to import history from '{{fileName}}' an error occurred.",
|
||||
"textWithoutFile": "You did not provide any files to upload the history from.",
|
||||
"tooNewVersion": "The file '{{fileName}}' comes from a newer client and can't be imported."
|
||||
},
|
||||
"removeNote": {
|
||||
"title": "Remove note from history",
|
||||
"question": "Do you really want to remove this note from your history?",
|
||||
"warning": "This just removes the history entry and won't delete the note itself.",
|
||||
"button": "Remove note from history"
|
||||
}
|
||||
},
|
||||
"tableHeader": {
|
||||
"title": "Title",
|
||||
"actions": "Actions",
|
||||
"tags": "Tags",
|
||||
"lastVisit": "Last Visit"
|
||||
},
|
||||
"menu": {
|
||||
"recentNotes": "Recent notes",
|
||||
"entryLocal": "Saved in your browser history",
|
||||
"entryRemote": "Saved in your user history",
|
||||
"removeEntry": "Remove from history",
|
||||
"deleteNote": "Delete note"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"intro": "Intro",
|
||||
"history": "History",
|
||||
"newGuestNote": "New guest note",
|
||||
"newNote": "New note"
|
||||
},
|
||||
|
@ -509,7 +429,6 @@
|
|||
"views": {
|
||||
"presentation": {},
|
||||
"readOnly": {
|
||||
"viewCount": "views",
|
||||
"editNote": "Edit this note"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntry, HistoryEntryPutDto, HistoryEntryWithOrigin } from './types'
|
||||
import { HistoryEntryOrigin } from './types'
|
||||
|
||||
/**
|
||||
* Transform a {@link HistoryEntry} into a {@link HistoryEntryWithOrigin}.
|
||||
*
|
||||
* @param entry the entry to build from
|
||||
* @return the history entry with an origin
|
||||
*/
|
||||
export const addRemoteOriginToHistoryEntry = (entry: HistoryEntry): HistoryEntryWithOrigin => {
|
||||
return {
|
||||
...entry,
|
||||
origin: HistoryEntryOrigin.REMOTE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link HistoryEntryPutDto} from a {@link HistoryEntry}.
|
||||
*
|
||||
* @param entry the entry to build the dto from
|
||||
* @return the dto for the api
|
||||
*/
|
||||
export const historyEntryToHistoryEntryPutDto = (entry: HistoryEntry): HistoryEntryPutDto => {
|
||||
return {
|
||||
pinStatus: entry.pinStatus,
|
||||
lastVisitedAt: entry.lastVisitedAt,
|
||||
note: entry.identifier
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
||||
import type { ChangePinStatusDto, HistoryEntry, HistoryEntryPutDto } from './types'
|
||||
|
||||
/**
|
||||
* Fetches the remote history for the user from the server.
|
||||
*
|
||||
* @return The remote history entries of the user.
|
||||
* @throws {Error} when the api request wasn't successful.
|
||||
*/
|
||||
export const getRemoteHistory = async (): Promise<HistoryEntry[]> => {
|
||||
const response = await new GetApiRequestBuilder<HistoryEntry[]>('me/history').sendRequest()
|
||||
return response.asParsedJsonObject()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the remote history of the user with the given history entries.
|
||||
*
|
||||
* @param entries The history entries to store remotely.
|
||||
* @throws {Error} when the api request wasn't successful.
|
||||
*/
|
||||
export const setRemoteHistoryEntries = async (entries: HistoryEntryPutDto[]): Promise<void> => {
|
||||
await new PostApiRequestBuilder<void, HistoryEntryPutDto[]>('me/history').withJsonBody(entries).sendRequest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a remote history entry's pin state.
|
||||
*
|
||||
* @param noteIdOrAlias The note id for which to update the pinning state.
|
||||
* @param pinStatus True when the note should be pinned, false otherwise.
|
||||
* @throws {Error} when the api request wasn't successful.
|
||||
*/
|
||||
export const updateRemoteHistoryEntryPinStatus = async (
|
||||
noteIdOrAlias: string,
|
||||
pinStatus: boolean
|
||||
): Promise<HistoryEntry> => {
|
||||
const response = await new PutApiRequestBuilder<HistoryEntry, ChangePinStatusDto>('me/history/' + noteIdOrAlias)
|
||||
.withJsonBody({
|
||||
pinStatus
|
||||
})
|
||||
.sendRequest()
|
||||
return response.asParsedJsonObject()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a remote history entry.
|
||||
*
|
||||
* @param noteIdOrAlias The note id or alias of the history entry to remove.
|
||||
* @throws {Error} when the api request wasn't successful.
|
||||
*/
|
||||
export const deleteRemoteHistoryEntry = async (noteIdOrAlias: string): Promise<void> => {
|
||||
await new DeleteApiRequestBuilder('me/history/' + noteIdOrAlias).sendRequest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the complete remote history.
|
||||
*
|
||||
* @throws {Error} when the api request wasn't successful.
|
||||
*/
|
||||
export const deleteRemoteHistory = async (): Promise<void> => {
|
||||
await new DeleteApiRequestBuilder('me/history').sendRequest()
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
export enum HistoryEntryOrigin {
|
||||
LOCAL = 'local',
|
||||
REMOTE = 'remote'
|
||||
}
|
||||
|
||||
export interface HistoryEntryPutDto {
|
||||
note: string
|
||||
pinStatus: boolean
|
||||
lastVisitedAt: string
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
identifier: string
|
||||
title: string
|
||||
owner: string | null
|
||||
lastVisitedAt: string
|
||||
tags: string[]
|
||||
pinStatus: boolean
|
||||
}
|
||||
|
||||
export interface HistoryEntryWithOrigin extends HistoryEntry {
|
||||
origin: HistoryEntryOrigin
|
||||
}
|
||||
|
||||
export interface ChangePinStatusDto {
|
||||
pinStatus: boolean
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { HistoryContent } from '../../../components/history-page/history-content/history-content'
|
||||
import { HistoryToolbar } from '../../../components/history-page/history-toolbar/history-toolbar'
|
||||
import { useSafeRefreshHistoryStateCallback } from '../../../components/history-page/history-toolbar/hooks/use-safe-refresh-history-state'
|
||||
import { HistoryToolbarStateContextProvider } from '../../../components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider'
|
||||
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||
import type { NextPage } from 'next'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Row } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* The page that shows the local and remote note history.
|
||||
*/
|
||||
const HistoryPage: NextPage = () => {
|
||||
useTranslation()
|
||||
|
||||
const safeRefreshHistoryStateCallback = useSafeRefreshHistoryStateCallback()
|
||||
useEffect(() => {
|
||||
safeRefreshHistoryStateCallback()
|
||||
}, [safeRefreshHistoryStateCallback])
|
||||
|
||||
return (
|
||||
<LandingLayout>
|
||||
<HistoryToolbarStateContextProvider>
|
||||
<Row className={'justify-content-center mt-5 mb-3'}>
|
||||
<HistoryToolbar />
|
||||
</Row>
|
||||
<HistoryContent />
|
||||
</HistoryToolbarStateContextProvider>
|
||||
</LandingLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryPage
|
|
@ -3,7 +3,6 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { refreshHistoryState } from '../../../redux/history/methods'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import { isDevMode, isTestMode } from '../../../utils/test-modes'
|
||||
import { loadDarkMode } from './load-dark-mode'
|
||||
|
@ -66,10 +65,6 @@ export const createSetUpTaskList = (): InitTask[] => {
|
|||
name: 'Fetch user information',
|
||||
task: fetchUserInformation
|
||||
},
|
||||
{
|
||||
name: 'Load history state',
|
||||
task: refreshHistoryState
|
||||
},
|
||||
{
|
||||
name: 'Load preferences',
|
||||
task: loadFromLocalStorageAsync
|
||||
|
|
|
@ -11,7 +11,6 @@ import { useComponentsFromAppExtensions } from './editor-pane/hooks/use-componen
|
|||
import { useNoteAndAppTitle } from './head-meta-properties/use-note-and-app-title'
|
||||
import { useScrollState } from './hooks/use-scroll-state'
|
||||
import { useSetScrollSource } from './hooks/use-set-scroll-source'
|
||||
import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry'
|
||||
import { RendererPane } from './renderer-pane/renderer-pane'
|
||||
import { Sidebar } from './sidebar/sidebar'
|
||||
import { Splitter } from './splitter/splitter'
|
||||
|
@ -32,7 +31,6 @@ export enum ScrollSource {
|
|||
export const EditorPageContent: React.FC = () => {
|
||||
useTranslation()
|
||||
usePrintKeyboardShortcut()
|
||||
useUpdateLocalHistoryEntry()
|
||||
|
||||
const scrollSource = useRef<ScrollSource>(ScrollSource.EDITOR)
|
||||
const [editorScrollState, onMarkdownRendererScroll] = useScrollState(scrollSource, ScrollSource.EDITOR)
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../../api/history/types'
|
||||
import { HistoryEntryOrigin } from '../../../api/history/types'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { getGlobalState } from '../../../redux'
|
||||
import { updateLocalHistoryEntry } from '../../../redux/history/methods'
|
||||
import equal from 'fast-deep-equal'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* An effect that uses information of the current note state to update a local {@link HistoryEntryWithOrigin history entry}.
|
||||
* The entry is updated when the title or tags of the note change.
|
||||
*/
|
||||
export const useUpdateLocalHistoryEntry = (): void => {
|
||||
const id = useApplicationState((state) => state.noteDetails?.id)
|
||||
const userExists = useApplicationState((state) => !!state.user)
|
||||
const currentNoteTitle = useApplicationState((state) => state.noteDetails?.title ?? '')
|
||||
const currentNoteTags = useApplicationState((state) => state.noteDetails?.frontmatter.tags ?? [])
|
||||
const currentNoteOwner = useApplicationState((state) => state.noteDetails?.permissions.owner)
|
||||
const lastNoteTitle = useRef('')
|
||||
const lastNoteTags = useRef<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (userExists || id === undefined) {
|
||||
return
|
||||
}
|
||||
if (currentNoteTitle === lastNoteTitle.current && equal(currentNoteTags, lastNoteTags.current)) {
|
||||
return
|
||||
}
|
||||
const history = getGlobalState().history
|
||||
const entry: HistoryEntryWithOrigin = history.find((entry) => entry.identifier === id) ?? {
|
||||
identifier: id,
|
||||
title: '',
|
||||
pinStatus: false,
|
||||
lastVisitedAt: '',
|
||||
tags: [],
|
||||
origin: HistoryEntryOrigin.LOCAL,
|
||||
owner: null
|
||||
}
|
||||
if (entry.origin === HistoryEntryOrigin.REMOTE) {
|
||||
return
|
||||
}
|
||||
const updatedEntry = { ...entry }
|
||||
updatedEntry.title = currentNoteTitle
|
||||
updatedEntry.tags = currentNoteTags
|
||||
updatedEntry.owner = currentNoteOwner
|
||||
updatedEntry.lastVisitedAt = new Date().toISOString()
|
||||
updateLocalHistoryEntry(id, updatedEntry)
|
||||
lastNoteTitle.current = currentNoteTitle
|
||||
lastNoteTags.current = currentNoteTags
|
||||
}, [id, userExists, currentNoteTitle, currentNoteTags, currentNoteOwner])
|
||||
}
|
|
@ -24,24 +24,24 @@ const validAliasRegex = /^[a-z0-9_-]*$/
|
|||
*/
|
||||
export const AliasesAddForm: React.FC = () => {
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const isOwner = useIsOwner()
|
||||
const [newAlias, setNewAlias] = useState('')
|
||||
|
||||
const onAddAlias = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (noteId === undefined) {
|
||||
if (noteAlias === undefined) {
|
||||
return
|
||||
}
|
||||
addAlias(noteId, newAlias)
|
||||
addAlias(noteAlias, newAlias)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification('editor.modal.aliases.errorAddingAlias'))
|
||||
.finally(() => {
|
||||
setNewAlias('')
|
||||
})
|
||||
},
|
||||
[noteId, newAlias, setNewAlias, showErrorNotification]
|
||||
[noteAlias, newAlias, setNewAlias, showErrorNotification]
|
||||
)
|
||||
|
||||
const onNewAliasInputChange = useOnInputChange(setNewAlias)
|
||||
|
|
|
@ -15,10 +15,10 @@ import { Badge } from 'react-bootstrap'
|
|||
import { Button } from 'react-bootstrap'
|
||||
import { Star as IconStar, X as IconX } from 'react-bootstrap-icons'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import type { AliasDto } from '@hedgedoc/commons'
|
||||
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
|
||||
|
||||
export interface AliasesListEntryProps {
|
||||
alias: AliasDto
|
||||
alias: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -29,16 +29,17 @@ export interface AliasesListEntryProps {
|
|||
export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) => {
|
||||
const { t } = useTranslation()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const primaryAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const isOwner = useIsOwner()
|
||||
|
||||
const onRemoveClick = useCallback(() => {
|
||||
deleteAlias(alias.name)
|
||||
deleteAlias(alias)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification(t('editor.modal.aliases.errorRemovingAlias')))
|
||||
}, [alias, t, showErrorNotification])
|
||||
|
||||
const onMakePrimaryClick = useCallback(() => {
|
||||
markAliasAsPrimary(alias.name)
|
||||
markAliasAsPrimary(alias)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification(t('editor.modal.aliases.errorMakingPrimary')))
|
||||
}, [alias, t, showErrorNotification])
|
||||
|
@ -50,15 +51,15 @@ export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) =>
|
|||
return (
|
||||
<li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}>
|
||||
<div>
|
||||
{alias.name}
|
||||
{alias.primaryAlias && (
|
||||
{alias}
|
||||
{alias === primaryAlias && (
|
||||
<Badge bg='secondary' className={'ms-2'} title={isPrimaryText} {...testId('aliasPrimaryBadge')}>
|
||||
<Trans i18nKey={'editor.modal.aliases.primaryLabel'}></Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{!alias.primaryAlias && (
|
||||
{alias !== primaryAlias && (
|
||||
<Button
|
||||
className={'me-2'}
|
||||
variant='secondary'
|
||||
|
|
|
@ -17,31 +17,17 @@ jest.mock('./aliases-list-entry')
|
|||
describe('AliasesList', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
const primaryAlias = 'a-test'
|
||||
mockAppState({
|
||||
noteDetails: {
|
||||
aliases: [
|
||||
{
|
||||
name: 'a-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: false
|
||||
},
|
||||
{
|
||||
name: 'z-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: false
|
||||
},
|
||||
{
|
||||
name: 'b-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: true
|
||||
}
|
||||
]
|
||||
aliases: ['a-test', 'b-test', 'z-test'],
|
||||
primaryAlias: primaryAlias
|
||||
}
|
||||
})
|
||||
jest.spyOn(AliasesListEntryModule, 'AliasesListEntry').mockImplementation((({ alias }) => {
|
||||
return (
|
||||
<span>
|
||||
Alias: {alias.name} ({alias.primaryAlias ? 'primary' : 'non-primary'})
|
||||
Alias: {alias} ({alias === primaryAlias ? 'primary' : 'non-primary'})
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<AliasesListEntryProps>)
|
||||
|
|
|
@ -7,7 +7,6 @@ import { useApplicationState } from '../../../../../../hooks/common/use-applicat
|
|||
import type { ApplicationState } from '../../../../../../redux'
|
||||
import { AliasesListEntry } from './aliases-list-entry'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import type { AliasDto } from '@hedgedoc/commons'
|
||||
|
||||
/**
|
||||
* Renders the list of aliases.
|
||||
|
@ -18,8 +17,8 @@ export const AliasesList: React.FC = () => {
|
|||
return aliases === undefined
|
||||
? null
|
||||
: Object.assign([], aliases)
|
||||
.sort((a: AliasDto, b: AliasDto) => a.name.localeCompare(b.name))
|
||||
.map((alias: AliasDto) => <AliasesListEntry alias={alias} key={alias.name} />)
|
||||
.sort((a: string, b: string) => a.localeCompare(b))
|
||||
.map((alias: string) => <AliasesListEntry alias={alias} key={alias} />)
|
||||
}, [aliases])
|
||||
|
||||
return <Fragment>{aliasesDom}</Fragment>
|
||||
|
|
|
@ -28,21 +28,21 @@ export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarE
|
|||
useTranslation()
|
||||
const userIsOwner = useIsOwner()
|
||||
const router = useRouter()
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const [modalVisibility, showModal, closeModal] = useBooleanState()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const deleteNoteAndCloseDialog = useCallback(
|
||||
(keepMedia: boolean) => {
|
||||
if (noteId === undefined) {
|
||||
if (noteAlias === undefined) {
|
||||
return
|
||||
}
|
||||
deleteNote(noteId, keepMedia)
|
||||
deleteNote(noteAlias, keepMedia)
|
||||
.then(() => router.push('/history'))
|
||||
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
|
||||
.finally(closeModal)
|
||||
},
|
||||
[closeModal, noteId, router, showErrorNotification]
|
||||
[closeModal, noteAlias, router, showErrorNotification]
|
||||
)
|
||||
|
||||
if (!userIsOwner) {
|
||||
|
|
|
@ -38,42 +38,42 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
|
|||
disabled,
|
||||
inconsistent
|
||||
}) => {
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const { t } = useTranslation()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const onSetEntryReadOnly = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setGroupPermission(noteId, type, false)
|
||||
setGroupPermission(noteAlias, type, false)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, showErrorNotification, type])
|
||||
}, [noteAlias, showErrorNotification, type])
|
||||
|
||||
const onSetEntryWriteable = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setGroupPermission(noteId, type, true)
|
||||
setGroupPermission(noteAlias, type, true)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, showErrorNotification, type])
|
||||
}, [noteAlias, showErrorNotification, type])
|
||||
|
||||
const onSetEntryDenied = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
removeGroupPermission(noteId, type)
|
||||
removeGroupPermission(noteAlias, type)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, showErrorNotification, type])
|
||||
}, [noteAlias, showErrorNotification, type])
|
||||
|
||||
const name = useMemo(() => {
|
||||
switch (type) {
|
||||
|
|
|
@ -33,7 +33,7 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps & Permission
|
|||
entry,
|
||||
disabled
|
||||
}) => {
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const { [SpecialGroup.EVERYONE]: everyonePermission, [SpecialGroup.LOGGED_IN]: loggedInPermission } =
|
||||
useGetSpecialPermissions()
|
||||
|
@ -46,37 +46,37 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps & Permission
|
|||
)
|
||||
|
||||
const onRemoveEntry = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
removeUserPermission(noteId, entry.username)
|
||||
removeUserPermission(noteAlias, entry.username)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, entry.username, showErrorNotification])
|
||||
}, [noteAlias, entry.username, showErrorNotification])
|
||||
|
||||
const onSetEntryReadOnly = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setUserPermission(noteId, entry.username, false)
|
||||
setUserPermission(noteAlias, entry.username, false)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, entry.username, showErrorNotification])
|
||||
}, [noteAlias, entry.username, showErrorNotification])
|
||||
|
||||
const onSetEntryWriteable = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setUserPermission(noteId, entry.username, true)
|
||||
setUserPermission(noteAlias, entry.username, true)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
}, [noteId, entry.username, showErrorNotification])
|
||||
}, [noteAlias, entry.username, showErrorNotification])
|
||||
|
||||
const { value, loading, error } = useAsync(async () => {
|
||||
return await getUserInfo(entry.username)
|
||||
|
|
|
@ -20,7 +20,7 @@ import { cypressId } from '../../../../../../utils/cypress-attribute'
|
|||
* @param disabled If the user is not the owner, functionality is disabled.
|
||||
*/
|
||||
export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ disabled }) => {
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const [changeOwner, setChangeOwner] = useState(false)
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
|
@ -30,10 +30,10 @@ export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ disa
|
|||
|
||||
const onOwnerChange = useCallback(
|
||||
(newOwner: string) => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setNoteOwner(noteId, newOwner)
|
||||
setNoteOwner(noteAlias, newOwner)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
|
@ -42,7 +42,7 @@ export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ disa
|
|||
setChangeOwner(false)
|
||||
})
|
||||
},
|
||||
[noteId, showErrorNotification]
|
||||
[noteAlias, showErrorNotification]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
@ -21,7 +21,7 @@ import { Trans, useTranslation } from 'react-i18next'
|
|||
export const PermissionSectionUsers: React.FC<PermissionDisabledProps> = ({ disabled }) => {
|
||||
useTranslation()
|
||||
const userPermissions = useApplicationState((state) => state.noteDetails?.permissions.sharedToUsers)
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const userEntries = useMemo(() => {
|
||||
|
@ -35,16 +35,16 @@ export const PermissionSectionUsers: React.FC<PermissionDisabledProps> = ({ disa
|
|||
|
||||
const onAddEntry = useCallback(
|
||||
(username: string) => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setUserPermission(noteId, username, false)
|
||||
setUserPermission(noteAlias, username, false)
|
||||
.then((updatedPermissions) => {
|
||||
setNotePermissionsFromServer(updatedPermissions)
|
||||
})
|
||||
.catch(showErrorNotification('editor.modal.permissions.error'))
|
||||
},
|
||||
[noteId, showErrorNotification]
|
||||
[noteAlias, showErrorNotification]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
@ -4,9 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { toggleHistoryEntryPinning } from '../../../../../redux/history/methods'
|
||||
import { concatCssClasses } from '../../../../../utils/concat-css-classes'
|
||||
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
||||
import { SidebarButton } from '../../sidebar-button/sidebar-button'
|
||||
import type { SpecificSidebarEntryProps } from '../../types'
|
||||
import styles from './pin-note-sidebar-entry.module.css'
|
||||
|
@ -24,27 +22,19 @@ import { WaitSpinner } from '../../../../common/wait-spinner/wait-spinner'
|
|||
export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ className, hide }) => {
|
||||
useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const history = useApplicationState((state) => state.history)
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
|
||||
const isPinned = useMemo(() => {
|
||||
const entry = history.find((entry) => entry.identifier === noteId)
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
return entry.pinStatus
|
||||
}, [history, noteId])
|
||||
// TODO Fix this when implementing the explore page
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const onPinClicked = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
toggleHistoryEntryPinning(noteId)
|
||||
.catch(showErrorNotification('landing.history.error.updateEntry.text'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [noteId, setLoading, showErrorNotification])
|
||||
}, [noteAlias, setLoading])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
|
@ -21,14 +21,14 @@ import { Trans } from 'react-i18next'
|
|||
*/
|
||||
export const RevisionDeleteModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const noteId = useApplicationState((state) => state.noteDetails?.id)
|
||||
const noteAlias = useApplicationState((state) => state.noteDetails?.primaryAlias)
|
||||
|
||||
const deleteAllRevisions = useCallback(() => {
|
||||
if (!noteId) {
|
||||
if (!noteAlias) {
|
||||
return
|
||||
}
|
||||
deleteRevisionsForNote(noteId).catch(showErrorNotification('editor.modal.deleteRevision.error')).finally(onHide)
|
||||
}, [noteId, onHide, showErrorNotification])
|
||||
deleteRevisionsForNote(noteAlias).catch(showErrorNotification('editor.modal.deleteRevision.error')).finally(onHide)
|
||||
}, [noteAlias, onHide, showErrorNotification])
|
||||
|
||||
return (
|
||||
<CommonModal
|
||||
|
|
|
@ -76,7 +76,7 @@ export const RevisionListEntry: React.FC<RevisionListEntryProps> = ({ active, on
|
|||
</span>
|
||||
<span>
|
||||
<UiIcon icon={IconPersonPlus} className='mx-2' />
|
||||
<Trans i18nKey={'editor.modal.revision.guestCount'} />: {revision.anonymousAuthorCount}
|
||||
<Trans i18nKey={'editor.modal.revision.guestCount'} />: {revision.authorGuestUuids.length}
|
||||
</span>
|
||||
</ListGroup.Item>
|
||||
)
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React, { Fragment } from 'react'
|
||||
import { Trash as IconTrash } from 'react-bootstrap-icons'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { DeleteNoteModal } from '../../editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal'
|
||||
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
|
||||
|
||||
export interface DeleteNoteItemProps {
|
||||
onConfirm: (keepMedia: boolean) => void
|
||||
noteTitle: string
|
||||
isOwner: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dropdown item for the {@link EntryMenu history entry menu} that allows to delete the note of the entry.
|
||||
*
|
||||
* @param noteTitle The title of the note to delete to show it in the deletion confirmation modal
|
||||
* @param onConfirm The callback that is fired when the deletion is confirmed
|
||||
*/
|
||||
export const DeleteNoteItem: React.FC<DeleteNoteItemProps> = ({ noteTitle, onConfirm, isOwner }) => {
|
||||
const [isModalVisible, showModal, hideModal] = useBooleanState()
|
||||
return (
|
||||
<Fragment>
|
||||
<Dropdown.Item onClick={showModal}>
|
||||
<UiIcon icon={IconTrash} className='mx-2' />
|
||||
<Trans i18nKey={'landing.history.menu.deleteNote'} />
|
||||
</Dropdown.Item>
|
||||
<DeleteNoteModal
|
||||
optionalNoteTitle={noteTitle}
|
||||
onConfirm={onConfirm}
|
||||
show={isModalVisible}
|
||||
onHide={hideModal}
|
||||
overrideIsOwner={isOwner}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.history-menu {
|
||||
&:global(.btn) {
|
||||
padding: 0.6rem 0.65rem;
|
||||
}
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { HistoryEntryOrigin } from '../../../api/history/types'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { DeleteNoteItem } from './delete-note-item'
|
||||
import styles from './entry-menu.module.scss'
|
||||
import { RemoveNoteEntryItem } from './remove-note-entry-item'
|
||||
import React from 'react'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { Cloud as IconCloud, Laptop as IconLaptop, ThreeDots as IconThreeDots } from 'react-bootstrap-icons'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
|
||||
export interface EntryMenuProps {
|
||||
id: string
|
||||
title: string
|
||||
origin: HistoryEntryOrigin
|
||||
noteOwner: string | null
|
||||
onRemoveFromHistory: () => void
|
||||
onDeleteNote: (keepMedia: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dropdown menu for a history entry containing options like removing the entry or deleting the note.
|
||||
*
|
||||
* @param id The unique identifier of the history entry.
|
||||
* @param title The title of the note of the history entry.
|
||||
* @param origin The origin of the entry. Must be either {@link HistoryEntryOrigin.LOCAL} or {@link HistoryEntryOrigin.REMOTE}.
|
||||
* @param noteOwner The username of the note owner.
|
||||
* @param onRemoveFromHistory Callback that is fired when the entry should be removed from the history.
|
||||
* @param onDeleteNote Callback that is fired when the note should be deleted.
|
||||
* @param className Additional CSS classes to add to the dropdown.
|
||||
*/
|
||||
export const EntryMenu: React.FC<EntryMenuProps> = ({
|
||||
id,
|
||||
title,
|
||||
origin,
|
||||
noteOwner,
|
||||
onRemoveFromHistory,
|
||||
onDeleteNote,
|
||||
className
|
||||
}) => {
|
||||
useTranslation()
|
||||
const userExists = useIsLoggedIn()
|
||||
const currentUsername = useApplicationState((state) => state.user?.username)
|
||||
|
||||
return (
|
||||
<Dropdown className={`d-inline-flex ${className || ''}`} {...cypressId('history-entry-menu')}>
|
||||
<Dropdown.Toggle
|
||||
variant={'secondary'}
|
||||
id={`dropdown-card-${id}`}
|
||||
className={`no-arrow ${styles['history-menu']} d-inline-flex align-items-center`}>
|
||||
<UiIcon icon={IconThreeDots} />
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Header>
|
||||
<Trans i18nKey='landing.history.menu.recentNotes' />
|
||||
</Dropdown.Header>
|
||||
|
||||
{origin === HistoryEntryOrigin.LOCAL && (
|
||||
<Dropdown.Item disabled>
|
||||
<UiIcon icon={IconLaptop} className='mx-2' />
|
||||
<Trans i18nKey='landing.history.menu.entryLocal' />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{origin === HistoryEntryOrigin.REMOTE && (
|
||||
<Dropdown.Item disabled>
|
||||
<UiIcon icon={IconCloud} className='mx-2' />
|
||||
<Trans i18nKey='landing.history.menu.entryRemote' />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
<RemoveNoteEntryItem onConfirm={onRemoveFromHistory} noteTitle={title} />
|
||||
|
||||
{userExists && currentUsername === noteOwner && (
|
||||
<>
|
||||
<Dropdown.Divider />
|
||||
<DeleteNoteItem onConfirm={onDeleteNote} noteTitle={title} isOwner={true} />
|
||||
</>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React, { Fragment } from 'react'
|
||||
import { Archive as IconArchive } from 'react-bootstrap-icons'
|
||||
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { DeletionModal } from '../../common/modals/deletion-modal'
|
||||
|
||||
export interface RemoveNoteEntryItemProps {
|
||||
onConfirm: () => void
|
||||
noteTitle: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a menu item for note deletion with a modal for confirmation.
|
||||
*
|
||||
* @param noteTitle The title of the note
|
||||
* @param onConfirm The callback to delete the note
|
||||
*/
|
||||
export const RemoveNoteEntryItem: React.FC<RemoveNoteEntryItemProps> = ({ noteTitle, onConfirm }) => {
|
||||
const [isModalVisible, showModal, hideModal] = useBooleanState()
|
||||
return (
|
||||
<Fragment>
|
||||
<Dropdown.Item onClick={showModal}>
|
||||
<UiIcon icon={IconArchive} className='mx-2' />
|
||||
<Trans i18nKey={'landing.history.menu.removeEntry'} />
|
||||
</Dropdown.Item>
|
||||
<DeletionModal
|
||||
deletionButtonI18nKey={'landing.history.modal.removeNote.button'}
|
||||
onConfirm={onConfirm}
|
||||
show={isModalVisible}
|
||||
onHide={hideModal}
|
||||
titleI18nKey={'landing.history.modal.removeNote.title'}>
|
||||
<h5>
|
||||
<Trans i18nKey={'landing.history.modal.removeNote.question'} />
|
||||
</h5>
|
||||
<ul>
|
||||
<li>{noteTitle}</li>
|
||||
</ul>
|
||||
<h6>
|
||||
<Trans i18nKey={'landing.history.modal.removeNote.warning'} />
|
||||
</h6>
|
||||
</DeletionModal>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Pager } from '../../common/pagination/pager'
|
||||
import type { HistoryEntriesProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||
import { HistoryCard } from './history-card'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Row } from 'react-bootstrap'
|
||||
|
||||
/**
|
||||
* Renders a paginated list of history entry cards.
|
||||
*
|
||||
* @param entries The history entries to render.
|
||||
* @param onPinClick Callback that is fired when the pinning button was clicked for an entry.
|
||||
* @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked for an entry.
|
||||
* @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked for an entry.
|
||||
* @param pageIndex The currently selected page.
|
||||
* @param onLastPageIndexChange Callback returning the last page index of the pager.
|
||||
*/
|
||||
export const HistoryCardList: React.FC<HistoryEntriesProps & HistoryEventHandlers> = ({
|
||||
entries,
|
||||
onPinClick,
|
||||
onRemoveEntryClick,
|
||||
onDeleteNoteClick,
|
||||
pageIndex,
|
||||
onLastPageIndexChange
|
||||
}) => {
|
||||
const entryCards = useMemo(() => {
|
||||
return entries.map((entry) => (
|
||||
<HistoryCard
|
||||
key={entry.identifier}
|
||||
entry={entry}
|
||||
onPinClick={onPinClick}
|
||||
onRemoveEntryClick={onRemoveEntryClick}
|
||||
onDeleteNoteClick={onDeleteNoteClick}
|
||||
/>
|
||||
))
|
||||
}, [entries, onPinClick, onRemoveEntryClick, onDeleteNoteClick])
|
||||
|
||||
return (
|
||||
<Row className='justify-content-start'>
|
||||
<Pager numberOfElementsPerPage={9} pageIndex={pageIndex} onLastPageIndexChange={onLastPageIndexChange}>
|
||||
{entryCards}
|
||||
</Pager>
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.card-min-height {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.card-footer-min-height {
|
||||
min-height: 27px;
|
||||
}
|
|
@ -1,106 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useDarkModeState } from '../../../hooks/dark-mode/use-dark-mode-state'
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { EntryMenu } from '../entry-menu/entry-menu'
|
||||
import type { HistoryEntryProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||
import { PinButton } from '../pin-button/pin-button'
|
||||
import { useHistoryEntryTitle } from '../use-history-entry-title'
|
||||
import { formatHistoryDate } from '../utils'
|
||||
import styles from './history-card.module.scss'
|
||||
import { DateTime } from 'luxon'
|
||||
import Link from 'next/link'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Badge, Card } from 'react-bootstrap'
|
||||
import { Clock as IconClock } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Renders a history entry as a card.
|
||||
*
|
||||
* @param entry The history entry.
|
||||
* @param onPinClick Callback that is fired when the pinning button was clicked.
|
||||
* @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked.
|
||||
* @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked.
|
||||
*/
|
||||
export const HistoryCard: React.FC<HistoryEntryProps & HistoryEventHandlers> = ({
|
||||
entry,
|
||||
onPinClick,
|
||||
onRemoveEntryClick,
|
||||
onDeleteNoteClick
|
||||
}) => {
|
||||
const onRemoveEntry = useCallback(() => {
|
||||
onRemoveEntryClick(entry.identifier)
|
||||
}, [onRemoveEntryClick, entry.identifier])
|
||||
|
||||
const onDeleteNote = useCallback(
|
||||
(keepMedia: boolean) => {
|
||||
onDeleteNoteClick(entry.identifier, keepMedia)
|
||||
},
|
||||
[onDeleteNoteClick, entry.identifier]
|
||||
)
|
||||
|
||||
const onPinEntry = useCallback(() => {
|
||||
onPinClick(entry.identifier)
|
||||
}, [onPinClick, entry.identifier])
|
||||
|
||||
const entryTitle = useHistoryEntryTitle(entry)
|
||||
|
||||
const darkModeState = useDarkModeState()
|
||||
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
entry.tags.map((tag) => {
|
||||
return (
|
||||
<Badge className={'bg-secondary text-light me-1 mb-1'} key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
)
|
||||
}),
|
||||
[entry.tags]
|
||||
)
|
||||
const lastVisited = useMemo(() => formatHistoryDate(entry.lastVisitedAt), [entry.lastVisitedAt])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='p-2 col-xs-12 col-sm-6 col-md-6 col-lg-4'
|
||||
{...cypressId('history-card')}
|
||||
{...cypressAttribute('card-title', entryTitle)}>
|
||||
<Card className={`${styles['card-min-height']}`} bg={darkModeState ? 'dark' : 'light'}>
|
||||
<Card.Body className='p-2 d-flex flex-row justify-content-between'>
|
||||
<div className={'d-flex flex-column'}>
|
||||
<PinButton isDark={false} isPinned={entry.pinStatus} onPinClick={onPinEntry} />
|
||||
</div>
|
||||
<Link href={`/n/${entry.identifier}`} className='text-decoration-none text-body-emphasis flex-fill'>
|
||||
<div className={'d-flex flex-column justify-content-between'}>
|
||||
<Card.Title className='m-0 mt-1dot5' {...cypressId('history-entry-title')}>
|
||||
{entryTitle}
|
||||
</Card.Title>
|
||||
<div>
|
||||
<div className='mt-2'>
|
||||
<UiIcon icon={IconClock} /> {DateTime.fromISO(entry.lastVisitedAt).toRelative()}
|
||||
<br />
|
||||
{lastVisited}
|
||||
</div>
|
||||
<div className={`${styles['card-footer-min-height']} p-0`}>{tags}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className={'d-flex flex-column'}>
|
||||
<EntryMenu
|
||||
id={entry.identifier}
|
||||
title={entryTitle}
|
||||
origin={entry.origin}
|
||||
onRemoveFromHistory={onRemoveEntry}
|
||||
onDeleteNote={onDeleteNote}
|
||||
noteOwner={entry.owner}
|
||||
/>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,130 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../../api/history/types'
|
||||
import { deleteNote } from '../../../api/notes'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { removeHistoryEntry, toggleHistoryEntryPinning } from '../../../redux/history/methods'
|
||||
import { PagerPagination } from '../../common/pagination/pager-pagination'
|
||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||
import { HistoryCardList } from '../history-card/history-card-list'
|
||||
import { HistoryTable } from '../history-table/history-table'
|
||||
import { ViewStateEnum } from '../history-toolbar/history-toolbar'
|
||||
import { useHistoryToolbarState } from '../history-toolbar/toolbar-context/use-history-toolbar-state'
|
||||
import { sortAndFilterEntries } from '../utils'
|
||||
import React, { Fragment, useCallback, useMemo, useState } from 'react'
|
||||
import { Alert, Col, Row } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
type OnEntryClick = (entryId: string) => void
|
||||
|
||||
export interface HistoryEventHandlers {
|
||||
onPinClick: OnEntryClick
|
||||
onRemoveEntryClick: OnEntryClick
|
||||
onDeleteNoteClick: (entryId: string, keepMedia: boolean) => void
|
||||
}
|
||||
|
||||
export interface HistoryEntryProps {
|
||||
entry: HistoryEntryWithOrigin
|
||||
}
|
||||
|
||||
export interface HistoryEntriesProps {
|
||||
entries: HistoryEntryWithOrigin[]
|
||||
pageIndex: number
|
||||
onLastPageIndexChange: (lastPageIndex: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content of the history based on the current history toolbar state.
|
||||
*/
|
||||
export const HistoryContent: React.FC = () => {
|
||||
useTranslation()
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
const [lastPageIndex, setLastPageIndex] = useState(0)
|
||||
|
||||
const allEntries = useApplicationState((state) => state.history)
|
||||
const [historyToolbarState] = useHistoryToolbarState()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const entriesToShow = useMemo<HistoryEntryWithOrigin[]>(
|
||||
() => sortAndFilterEntries(allEntries, historyToolbarState),
|
||||
[allEntries, historyToolbarState]
|
||||
)
|
||||
|
||||
const onPinClick = useCallback(
|
||||
(noteId: string) => {
|
||||
toggleHistoryEntryPinning(noteId).catch(showErrorNotification('landing.history.error.updateEntry.text'))
|
||||
},
|
||||
[showErrorNotification]
|
||||
)
|
||||
|
||||
const onDeleteClick = useCallback(
|
||||
(noteId: string, keepMedia: boolean) => {
|
||||
deleteNote(noteId, keepMedia)
|
||||
.then(() => removeHistoryEntry(noteId))
|
||||
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
|
||||
},
|
||||
[showErrorNotification]
|
||||
)
|
||||
|
||||
const onRemoveClick = useCallback(
|
||||
(noteId: string) => {
|
||||
removeHistoryEntry(noteId).catch(showErrorNotification('landing.history.error.deleteEntry.text'))
|
||||
},
|
||||
[showErrorNotification]
|
||||
)
|
||||
|
||||
const historyContent = useMemo(() => {
|
||||
switch (historyToolbarState.viewState) {
|
||||
case ViewStateEnum.TABLE:
|
||||
return (
|
||||
<HistoryTable
|
||||
entries={entriesToShow}
|
||||
onPinClick={onPinClick}
|
||||
onRemoveEntryClick={onRemoveClick}
|
||||
onDeleteNoteClick={onDeleteClick}
|
||||
pageIndex={pageIndex}
|
||||
onLastPageIndexChange={setLastPageIndex}
|
||||
/>
|
||||
)
|
||||
case ViewStateEnum.CARD:
|
||||
return (
|
||||
<HistoryCardList
|
||||
entries={entriesToShow}
|
||||
onPinClick={onPinClick}
|
||||
onRemoveEntryClick={onRemoveClick}
|
||||
onDeleteNoteClick={onDeleteClick}
|
||||
pageIndex={pageIndex}
|
||||
onLastPageIndexChange={setLastPageIndex}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}, [entriesToShow, historyToolbarState.viewState, onDeleteClick, onPinClick, onRemoveClick, pageIndex])
|
||||
|
||||
if (entriesToShow.length === 0) {
|
||||
return (
|
||||
<Row className={'justify-content-center'}>
|
||||
<Alert variant={'secondary'}>
|
||||
<Trans i18nKey={'landing.history.noHistory'} />
|
||||
</Alert>
|
||||
</Row>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Fragment>
|
||||
{historyContent}
|
||||
<Row>
|
||||
<Col className={'justify-content-center d-flex'}>
|
||||
<PagerPagination
|
||||
numberOfPageButtonsToShowAfterAndBeforeCurrent={2}
|
||||
lastPageIndex={lastPageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { EntryMenu } from '../entry-menu/entry-menu'
|
||||
import type { HistoryEntryProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||
import { PinButton } from '../pin-button/pin-button'
|
||||
import { useHistoryEntryTitle } from '../use-history-entry-title'
|
||||
import { formatHistoryDate } from '../utils'
|
||||
import Link from 'next/link'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
|
||||
/**
|
||||
* Renders a history entry as a table row.
|
||||
*
|
||||
* @param entry The history entry.
|
||||
* @param onPinClick Callback that is fired when the pinning button was clicked.
|
||||
* @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked.
|
||||
* @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked.
|
||||
*/
|
||||
export const HistoryTableRow: React.FC<HistoryEntryProps & HistoryEventHandlers> = ({
|
||||
entry,
|
||||
onPinClick,
|
||||
onRemoveEntryClick,
|
||||
onDeleteNoteClick
|
||||
}) => {
|
||||
const entryTitle = useHistoryEntryTitle(entry)
|
||||
|
||||
const onPinEntry = useCallback(() => {
|
||||
onPinClick(entry.identifier)
|
||||
}, [onPinClick, entry.identifier])
|
||||
|
||||
const onEntryRemove = useCallback(() => {
|
||||
onRemoveEntryClick(entry.identifier)
|
||||
}, [onRemoveEntryClick, entry.identifier])
|
||||
|
||||
const onDeleteNote = useCallback(
|
||||
(keepMedia: boolean) => {
|
||||
onDeleteNoteClick(entry.identifier, keepMedia)
|
||||
},
|
||||
[onDeleteNoteClick, entry.identifier]
|
||||
)
|
||||
|
||||
return (
|
||||
<tr {...cypressAttribute('entry-title', entryTitle)}>
|
||||
<td>
|
||||
<Link href={`/n/${entry.identifier}`} className='text-secondary' {...cypressId('history-entry-title')}>
|
||||
{entryTitle}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{formatHistoryDate(entry.lastVisitedAt)}</td>
|
||||
<td>
|
||||
{entry.tags.map((tag) => (
|
||||
<Badge className={'me-1 mb-1'} key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
<div className={'d-flex align-items-start justify-content-center'}>
|
||||
<PinButton isDark={true} isPinned={entry.pinStatus} onPinClick={onPinEntry} className={'mb-1 me-1'} />
|
||||
<EntryMenu
|
||||
id={entry.identifier}
|
||||
title={entryTitle}
|
||||
origin={entry.origin}
|
||||
noteOwner={entry.owner}
|
||||
onRemoveFromHistory={onEntryRemove}
|
||||
onDeleteNote={onDeleteNote}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.history-table tr {
|
||||
th, td {
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
width: 15%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useDarkModeState } from '../../../hooks/dark-mode/use-dark-mode-state'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { Pager } from '../../common/pagination/pager'
|
||||
import type { HistoryEntriesProps, HistoryEventHandlers } from '../history-content/history-content'
|
||||
import { HistoryTableRow } from './history-table-row'
|
||||
import styles from './history-table.module.scss'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Table } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Renders a paginated table of history entries.
|
||||
*
|
||||
* @param entries The history entries to render.
|
||||
* @param onPinClick Callback that is fired when the pinning button was clicked for an entry.
|
||||
* @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked for an entry.
|
||||
* @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked for an entry.
|
||||
* @param pageIndex The currently selected page.
|
||||
* @param onLastPageIndexChange Callback returning the last page index of the pager.
|
||||
*/
|
||||
export const HistoryTable: React.FC<HistoryEntriesProps & HistoryEventHandlers> = ({
|
||||
entries,
|
||||
onPinClick,
|
||||
onRemoveEntryClick,
|
||||
onDeleteNoteClick,
|
||||
pageIndex,
|
||||
onLastPageIndexChange
|
||||
}) => {
|
||||
useTranslation()
|
||||
|
||||
const tableRows = useMemo(() => {
|
||||
return entries.map((entry) => (
|
||||
<HistoryTableRow
|
||||
key={entry.identifier}
|
||||
entry={entry}
|
||||
onPinClick={onPinClick}
|
||||
onRemoveEntryClick={onRemoveEntryClick}
|
||||
onDeleteNoteClick={onDeleteNoteClick}
|
||||
/>
|
||||
))
|
||||
}, [entries, onPinClick, onRemoveEntryClick, onDeleteNoteClick])
|
||||
|
||||
const darkModeState = useDarkModeState()
|
||||
|
||||
return (
|
||||
<Table
|
||||
striped
|
||||
bordered
|
||||
hover
|
||||
size='sm'
|
||||
variant={darkModeState ? 'dark' : 'light'}
|
||||
className={styles['history-table']}
|
||||
{...cypressId('history-table')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey={'landing.history.tableHeader.title'} />
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey={'landing.history.tableHeader.lastVisit'} />
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey={'landing.history.tableHeader.tags'} />
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey={'landing.history.tableHeader.actions'} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Pager numberOfElementsPerPage={12} pageIndex={pageIndex} onLastPageIndexChange={onLastPageIndexChange}>
|
||||
{tableRows}
|
||||
</Pager>
|
||||
</tbody>
|
||||
</Table>
|
||||
)
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { deleteAllHistoryEntries } from '../../../redux/history/methods'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { DeletionModal } from '../../common/modals/deletion-modal'
|
||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
|
||||
import React, { Fragment, useCallback } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Trash as IconTrash } from 'react-bootstrap-icons'
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Renders a button to clear the complete history of the user.
|
||||
* A confirmation modal will be presented to the user after clicking the button.
|
||||
*/
|
||||
export const ClearHistoryButton: React.FC = () => {
|
||||
const [modalVisibility, showModal, closeModal] = useBooleanState()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
deleteAllHistoryEntries().catch((error: Error) => {
|
||||
showErrorNotification('landing.history.error.deleteEntry.text')(error)
|
||||
safeRefreshHistoryState()
|
||||
})
|
||||
closeModal()
|
||||
}, [closeModal, safeRefreshHistoryState, showErrorNotification])
|
||||
|
||||
const buttonTitle = useTranslatedText('landing.history.toolbar.clear')
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button variant={'secondary'} title={buttonTitle} onClick={showModal} {...cypressId('history-clear-button')}>
|
||||
<UiIcon icon={IconTrash} />
|
||||
</Button>
|
||||
<DeletionModal
|
||||
onConfirm={onConfirm}
|
||||
deletionButtonI18nKey={'landing.history.toolbar.clear'}
|
||||
show={modalVisibility}
|
||||
onHide={closeModal}
|
||||
titleI18nKey={'landing.history.modal.clearHistory.title'}>
|
||||
<h5>
|
||||
<Trans i18nKey={'landing.history.modal.clearHistory.question'} />
|
||||
</h5>
|
||||
<h6>
|
||||
<Trans i18nKey={'landing.history.modal.clearHistory.disclaimer'} />
|
||||
</h6>
|
||||
</DeletionModal>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { downloadHistory } from '../../../redux/history/methods'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import React from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Download as IconDownload } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Renders a button to export the history.
|
||||
*/
|
||||
export const ExportHistoryButton: React.FC = () => {
|
||||
const buttonTitle = useTranslatedText('landing.history.toolbar.export')
|
||||
|
||||
return (
|
||||
<Button variant={'secondary'} title={buttonTitle} onClick={downloadHistory}>
|
||||
<UiIcon icon={IconDownload} />
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
|
||||
import React from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Fetches the current history from the server.
|
||||
*/
|
||||
export const HistoryRefreshButton: React.FC = () => {
|
||||
const refreshHistory = useSafeRefreshHistoryStateCallback()
|
||||
const buttonTitle = useTranslatedText('landing.history.toolbar.refresh')
|
||||
|
||||
return (
|
||||
<Button variant={'secondary'} title={buttonTitle} onClick={refreshHistory}>
|
||||
<UiIcon icon={IconArrowRepeat} />
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { SortModeEnum } from '../sort-button/sort-button'
|
||||
import type { ViewStateEnum } from './history-toolbar'
|
||||
|
||||
export type HistoryToolbarState = {
|
||||
viewState: ViewStateEnum
|
||||
search: string
|
||||
selectedTags: string[]
|
||||
titleSortDirection: SortModeEnum
|
||||
lastVisitedSortDirection: SortModeEnum
|
||||
}
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { HistoryEntryOrigin } from '../../../api/history/types'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { importHistoryEntries, setHistoryEntries } from '../../../redux/history/methods'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||
import { ClearHistoryButton } from './clear-history-button'
|
||||
import { ExportHistoryButton } from './export-history-button'
|
||||
import { HistoryRefreshButton } from './history-refresh-button'
|
||||
import { HistoryViewModeToggleButton } from './history-view-mode-toggle-button'
|
||||
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
|
||||
import { ImportHistoryButton } from './import-history-button'
|
||||
import { KeywordSearchInput } from './keyword-search-input'
|
||||
import { SortByLastVisitedButton } from './sort-by-last-visited-button'
|
||||
import { SortByTitleButton } from './sort-by-title-button'
|
||||
import { TagSelectionInput } from './tag-selection-input'
|
||||
import { useSyncToolbarStateToUrlEffect } from './toolbar-context/use-sync-toolbar-state-to-url-effect'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Button, Col } from 'react-bootstrap'
|
||||
import { CloudUpload as IconCloudUpload } from 'react-bootstrap-icons'
|
||||
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
|
||||
|
||||
export enum ViewStateEnum {
|
||||
CARD,
|
||||
TABLE
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the toolbar for the history page that contains controls for filtering and sorting.
|
||||
*/
|
||||
export const HistoryToolbar: React.FC = () => {
|
||||
const historyEntries = useApplicationState((state) => state.history)
|
||||
const userExists = useIsLoggedIn()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
|
||||
useSyncToolbarStateToUrlEffect()
|
||||
|
||||
const onUploadAllToRemote = useCallback(() => {
|
||||
if (!userExists) {
|
||||
return
|
||||
}
|
||||
const localEntries = historyEntries
|
||||
.filter((entry) => entry.origin === HistoryEntryOrigin.LOCAL)
|
||||
.map((entry) => entry.identifier)
|
||||
historyEntries.forEach((entry) => (entry.origin = HistoryEntryOrigin.REMOTE))
|
||||
importHistoryEntries(historyEntries).catch((error: Error) => {
|
||||
showErrorNotification('landing.history.error.setHistory.text')(error)
|
||||
historyEntries.forEach((entry) => {
|
||||
if (localEntries.includes(entry.identifier)) {
|
||||
entry.origin = HistoryEntryOrigin.LOCAL
|
||||
}
|
||||
})
|
||||
setHistoryEntries(historyEntries)
|
||||
safeRefreshHistoryState()
|
||||
})
|
||||
}, [userExists, historyEntries, showErrorNotification, safeRefreshHistoryState])
|
||||
|
||||
const uploadAllButtonTitle = useTranslatedText('landing.history.toolbar.uploadAll')
|
||||
|
||||
return (
|
||||
<Col className={'d-flex flex-row flex-wrap'}>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<TagSelectionInput />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<KeywordSearchInput />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<SortByTitleButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<SortByLastVisitedButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<ExportHistoryButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<ImportHistoryButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<ClearHistoryButton />
|
||||
</div>
|
||||
<div className={'me-1 mb-1'}>
|
||||
<HistoryRefreshButton />
|
||||
</div>
|
||||
{userExists && (
|
||||
<div className={'me-1 mb-1'}>
|
||||
<Button variant={'secondary'} title={uploadAllButtonTitle} onClick={onUploadAllToRemote}>
|
||||
<UiIcon icon={IconCloudUpload} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className={'me-1 mb-1'}>
|
||||
<HistoryViewModeToggleButton />
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { ViewStateEnum } from './history-toolbar'
|
||||
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Button, ToggleButtonGroup } from 'react-bootstrap'
|
||||
import { StickyFill as IconStickyFill, Table as IconTable } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Toggles the view mode of the history entries between list and card view.
|
||||
*/
|
||||
export const HistoryViewModeToggleButton: React.FC = () => {
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const onViewStateChange = useCallback(
|
||||
(newViewState: ViewStateEnum) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
viewState: newViewState
|
||||
}))
|
||||
},
|
||||
[setHistoryToolbarState]
|
||||
)
|
||||
|
||||
const cardsButtonTitle = useTranslatedText('landing.history.toolbar.cards')
|
||||
const tableButtonTitle = useTranslatedText('landing.history.toolbar.table')
|
||||
|
||||
const onCardsButtonClick = useCallback(() => onViewStateChange(ViewStateEnum.CARD), [onViewStateChange])
|
||||
const onTableButtonClick = useCallback(() => onViewStateChange(ViewStateEnum.TABLE), [onViewStateChange])
|
||||
|
||||
return (
|
||||
<ToggleButtonGroup type='radio' name='options' dir='auto' className={'button-height'} onChange={onViewStateChange}>
|
||||
<Button
|
||||
title={cardsButtonTitle}
|
||||
variant={historyToolbarState.viewState === ViewStateEnum.CARD ? 'secondary' : 'outline-secondary'}
|
||||
onClick={onCardsButtonClick}>
|
||||
<UiIcon icon={IconStickyFill} className={'fa-fix-line-height'} />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('history-mode-table')}
|
||||
variant={historyToolbarState.viewState === ViewStateEnum.TABLE ? 'secondary' : 'outline-secondary'}
|
||||
title={tableButtonTitle}
|
||||
onClick={onTableButtonClick}>
|
||||
<UiIcon icon={IconTable} className={'fa-fix-line-height'} />
|
||||
</Button>
|
||||
</ToggleButtonGroup>
|
||||
)
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { refreshHistoryState } from '../../../../redux/history/methods'
|
||||
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Tries to refresh the history from the backend and shows notification if that request fails.
|
||||
*/
|
||||
export const useSafeRefreshHistoryStateCallback = () => {
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
return useCallback(() => {
|
||||
refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text'))
|
||||
}, [showErrorNotification])
|
||||
}
|
|
@ -1,142 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../../api/history/types'
|
||||
import { HistoryEntryOrigin } from '../../../api/history/types'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { convertV1History, importHistoryEntries, mergeHistoryEntries } from '../../../redux/history/methods'
|
||||
import type { HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Upload as IconUpload } from 'react-bootstrap-icons'
|
||||
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
|
||||
|
||||
/**
|
||||
* Button that lets the user select a history JSON file and uploads imports that into the history.
|
||||
*/
|
||||
export const ImportHistoryButton: React.FC = () => {
|
||||
const userExists = useIsLoggedIn()
|
||||
const historyState = useApplicationState((state) => state.history)
|
||||
const uploadInput = useRef<HTMLInputElement>(null)
|
||||
const [fileName, setFilename] = useState('')
|
||||
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
|
||||
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
|
||||
|
||||
const onImportHistory = useCallback(
|
||||
(entries: HistoryEntryWithOrigin[]): void => {
|
||||
entries.forEach((entry) => (entry.origin = userExists ? HistoryEntryOrigin.REMOTE : HistoryEntryOrigin.LOCAL))
|
||||
importHistoryEntries(mergeHistoryEntries(historyState, entries)).catch((error: Error) => {
|
||||
showErrorNotification('landing.history.error.setHistory.text')(error)
|
||||
safeRefreshHistoryState()
|
||||
})
|
||||
},
|
||||
[historyState, safeRefreshHistoryState, showErrorNotification, userExists]
|
||||
)
|
||||
|
||||
const resetInputField = useCallback(() => {
|
||||
if (!uploadInput.current) {
|
||||
return
|
||||
}
|
||||
uploadInput.current.value = ''
|
||||
}, [uploadInput])
|
||||
|
||||
const onUploadButtonClick = useCallback(() => uploadInput.current?.click(), [uploadInput])
|
||||
|
||||
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const { validity, files } = event.target
|
||||
if (files && files[0] && validity.valid) {
|
||||
const file = files[0]
|
||||
setFilename(file.name)
|
||||
if (file.type !== 'application/json' && file.type !== '') {
|
||||
void dispatchUiNotification('common.errorOccurred', 'landing.history.modal.importHistoryError.textWithFile', {
|
||||
contentI18nOptions: {
|
||||
fileName
|
||||
}
|
||||
})
|
||||
resetInputField()
|
||||
return
|
||||
}
|
||||
//TODO: [mrdrogdrog] The following whole block can be shortened using our `readFile` util.
|
||||
// But I won't do it right now because the whole components needs a make over and that's definitely out of scope for my current PR.
|
||||
// https://github.com/hedgedoc/hedgedoc/issues/5042
|
||||
const fileReader = new FileReader()
|
||||
fileReader.onload = (event) => {
|
||||
if (event.target && event.target.result) {
|
||||
try {
|
||||
const result = event.target.result as string
|
||||
const data = JSON.parse(result) as HistoryExportJson
|
||||
if (data) {
|
||||
if (data.version) {
|
||||
if (data.version === 2) {
|
||||
onImportHistory(data.entries)
|
||||
} else {
|
||||
// probably a newer version we can't support
|
||||
void dispatchUiNotification(
|
||||
'common.errorOccurred',
|
||||
'landing.history.modal.importHistoryError.tooNewVersion',
|
||||
{
|
||||
contentI18nOptions: {
|
||||
fileName
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const oldEntries = JSON.parse(result) as V1HistoryEntry[]
|
||||
onImportHistory(convertV1History(oldEntries))
|
||||
}
|
||||
}
|
||||
resetInputField()
|
||||
} catch {
|
||||
void dispatchUiNotification(
|
||||
'common.errorOccurred',
|
||||
'landing.history.modal.importHistoryError.textWithFile',
|
||||
{
|
||||
contentI18nOptions: {
|
||||
fileName
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fileReader.readAsText(file)
|
||||
} else {
|
||||
void dispatchUiNotification(
|
||||
'common.errorOccurred',
|
||||
'landing.history.modal.importHistoryError.textWithOutFile',
|
||||
{}
|
||||
)
|
||||
resetInputField()
|
||||
}
|
||||
}
|
||||
|
||||
const buttonTitle = useTranslatedText('landing.history.toolbar.import')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type='file'
|
||||
className='d-none'
|
||||
accept='.json'
|
||||
onChange={handleUpload}
|
||||
ref={uploadInput}
|
||||
{...cypressId('import-history-file-input')}
|
||||
/>
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
title={buttonTitle}
|
||||
onClick={onUploadButtonClick}
|
||||
{...cypressId('import-history-file-button')}>
|
||||
<UiIcon icon={IconUpload} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
|
||||
import React from 'react'
|
||||
import { FormControl } from 'react-bootstrap'
|
||||
|
||||
/**
|
||||
* A text input that is used to filter history entries for specific keywords.
|
||||
*/
|
||||
export const KeywordSearchInput: React.FC = () => {
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const onChange = useOnInputChange((search) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
search
|
||||
}))
|
||||
})
|
||||
|
||||
const searchKeywordsText = useTranslatedText('landing.history.toolbar.searchKeywords')
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
placeholder={searchKeywordsText}
|
||||
aria-label={searchKeywordsText}
|
||||
onChange={onChange}
|
||||
value={historyToolbarState.search}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { SortButton, SortModeEnum } from '../sort-button/sort-button'
|
||||
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Controls if history entries should be sorted by the last visited date.
|
||||
*/
|
||||
export const SortByLastVisitedButton: React.FC = () => {
|
||||
useTranslation()
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const lastVisitedSortChanged = useCallback(
|
||||
(direction: SortModeEnum) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
lastVisitedSortDirection: direction,
|
||||
titleSortDirection: SortModeEnum.no
|
||||
}))
|
||||
},
|
||||
[setHistoryToolbarState]
|
||||
)
|
||||
|
||||
return (
|
||||
<SortButton onDirectionChange={lastVisitedSortChanged} direction={historyToolbarState.lastVisitedSortDirection}>
|
||||
<Trans i18nKey={'landing.history.toolbar.sortByLastVisited'} />
|
||||
</SortButton>
|
||||
)
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { SortButton, SortModeEnum } from '../sort-button/sort-button'
|
||||
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Controls if history entries should be sorted by title.
|
||||
*/
|
||||
export const SortByTitleButton: React.FC = () => {
|
||||
useTranslation()
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const titleSortChanged = useCallback(
|
||||
(direction: SortModeEnum) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
lastVisitedSortDirection: SortModeEnum.no,
|
||||
titleSortDirection: direction
|
||||
}))
|
||||
},
|
||||
[setHistoryToolbarState]
|
||||
)
|
||||
|
||||
return (
|
||||
<SortButton onDirectionChange={titleSortChanged} direction={historyToolbarState.titleSortDirection}>
|
||||
<Trans i18nKey={'landing.history.toolbar.sortByTitle'} />
|
||||
</SortButton>
|
||||
)
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||
import { useHistoryToolbarState } from './toolbar-context/use-history-toolbar-state'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Typeahead } from 'react-bootstrap-typeahead'
|
||||
import type { Option } from 'react-bootstrap-typeahead/types/types'
|
||||
|
||||
/**
|
||||
* Renders an input field that filters history entries by selected tags.
|
||||
*/
|
||||
export const TagSelectionInput: React.FC = () => {
|
||||
const [historyToolbarState, setHistoryToolbarState] = useHistoryToolbarState()
|
||||
|
||||
const historyEntries = useApplicationState((state) => state.history)
|
||||
|
||||
const tags = useMemo<string[]>(() => {
|
||||
const allTags = historyEntries
|
||||
.map((entry) => entry.tags)
|
||||
.flat()
|
||||
.sort((first, second) => first.toLowerCase().localeCompare(second.toLowerCase()))
|
||||
return Array.from(new Set(allTags))
|
||||
}, [historyEntries])
|
||||
|
||||
const onChange = useCallback(
|
||||
(selectedTags: Option[]) => {
|
||||
setHistoryToolbarState((state) => ({
|
||||
...state,
|
||||
selectedTags: selectedTags as string[]
|
||||
}))
|
||||
},
|
||||
[setHistoryToolbarState]
|
||||
)
|
||||
|
||||
const placeholderText = useTranslatedText('landing.history.toolbar.selectTags')
|
||||
return (
|
||||
<Typeahead
|
||||
id={'tagsSelection'}
|
||||
options={tags}
|
||||
multiple={true}
|
||||
placeholder={placeholderText}
|
||||
onChange={onChange}
|
||||
selected={historyToolbarState.selectedTags}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useArrayStringUrlParameter } from '../../../../hooks/common/use-array-string-url-parameter'
|
||||
import { useSingleStringUrlParameter } from '../../../../hooks/common/use-single-string-url-parameter'
|
||||
import { SortModeEnum } from '../../sort-button/sort-button'
|
||||
import { ViewStateEnum } from '../history-toolbar'
|
||||
import type { HistoryToolbarState } from '../history-toolbar-state'
|
||||
import type { HistoryToolbarStateWithDispatcher } from './toolbar-context'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { createContext, useState } from 'react'
|
||||
|
||||
export const historyToolbarStateContext = createContext<HistoryToolbarStateWithDispatcher | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* Provides a {@link React.Context react context} for the current state of the toolbar.
|
||||
*
|
||||
* @param children The children that should receive the toolbar state via context.
|
||||
*/
|
||||
export const HistoryToolbarStateContextProvider: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||
const search = useSingleStringUrlParameter('search', '')
|
||||
const selectedTags = useArrayStringUrlParameter('selectedTags')
|
||||
|
||||
const stateWithDispatcher = useState<HistoryToolbarState>(() => ({
|
||||
viewState: ViewStateEnum.CARD,
|
||||
search: search,
|
||||
selectedTags: selectedTags,
|
||||
titleSortDirection: SortModeEnum.no,
|
||||
lastVisitedSortDirection: SortModeEnum.down
|
||||
}))
|
||||
|
||||
return (
|
||||
<historyToolbarStateContext.Provider value={stateWithDispatcher}>{children}</historyToolbarStateContext.Provider>
|
||||
)
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryToolbarState } from '../history-toolbar-state'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
export type HistoryToolbarStateWithDispatcher = [HistoryToolbarState, Dispatch<SetStateAction<HistoryToolbarState>>]
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { historyToolbarStateContext } from './history-toolbar-state-context-provider'
|
||||
import type { HistoryToolbarStateWithDispatcher } from './toolbar-context'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import { useContext } from 'react'
|
||||
|
||||
/**
|
||||
* Receives a {@link React.Context react context} for the history toolbar state.
|
||||
*
|
||||
* @throws Error if no context was set
|
||||
*/
|
||||
export const useHistoryToolbarState: () => HistoryToolbarStateWithDispatcher = () => {
|
||||
return Optional.ofNullable(useContext(historyToolbarStateContext)).orElseThrow(
|
||||
() => new Error('No toolbar context found. Did you forget to use the provider component?')
|
||||
)
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useHistoryToolbarState } from './use-history-toolbar-state'
|
||||
import equal from 'fast-deep-equal'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Pushes the current search and tag selection into the navigation history stack of the browser.
|
||||
*/
|
||||
export const useSyncToolbarStateToUrlEffect = (): void => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [state] = useHistoryToolbarState()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams || !pathname) {
|
||||
return
|
||||
}
|
||||
|
||||
const urlParameterSearch = searchParams.get('search') ?? ''
|
||||
const urlParameterSelectedTags = searchParams.getAll('selectedTags')
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
let shouldUpdate = false
|
||||
|
||||
if (!equal(state.search, urlParameterSearch)) {
|
||||
if (!state.search) {
|
||||
params.delete('search')
|
||||
} else {
|
||||
params.set('search', state.search)
|
||||
}
|
||||
shouldUpdate = true
|
||||
}
|
||||
if (!equal(state.selectedTags, urlParameterSelectedTags)) {
|
||||
params.delete('selectedTags')
|
||||
state.selectedTags.forEach((tag) => params.append('selectedTags', tag))
|
||||
shouldUpdate = true
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}
|
||||
}, [state, router, searchParams, pathname])
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.history-pin {
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
svg {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.pinned svg {
|
||||
color: #d43f3a;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import styles from './pin-button.module.scss'
|
||||
import React from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { PinFill as IconPinFill } from 'react-bootstrap-icons'
|
||||
|
||||
export interface PinButtonProps {
|
||||
isPinned: boolean
|
||||
onPinClick: () => void
|
||||
isDark: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a button with a pin icon.
|
||||
*
|
||||
* @param isPinned The initial state of this button.
|
||||
* @param onPinClick The callback, that is fired when the button is clicked.
|
||||
* @param isDark If the button should be rendered in dark or not.
|
||||
* @param className Additional classes directly given to the button
|
||||
*/
|
||||
export const PinButton: React.FC<PinButtonProps> = ({ isPinned, onPinClick, isDark, className }) => {
|
||||
return (
|
||||
<Button
|
||||
variant={isDark ? 'secondary' : 'secondary'}
|
||||
className={`${styles['history-pin']} ${className || ''} ${isPinned ? styles['pinned'] : ''}`}
|
||||
onClick={onPinClick}
|
||||
{...cypressId('history-entry-pin-button')}
|
||||
{...cypressAttribute('pinned', isPinned ? 'true' : 'false')}>
|
||||
<UiIcon icon={IconPinFill} />
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { IconButton } from '../../common/icon-button/icon-button'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import type { ButtonProps } from 'react-bootstrap'
|
||||
import { SortAlphaDown as IconSortAlphaDown, SortAlphaUp as IconSortAlphaUp, X as IconX } from 'react-bootstrap-icons'
|
||||
|
||||
export enum SortModeEnum {
|
||||
up = 1,
|
||||
down = -1,
|
||||
no = 0
|
||||
}
|
||||
|
||||
export interface SortButtonProps extends ButtonProps {
|
||||
onDirectionChange: (direction: SortModeEnum) => void
|
||||
direction: SortModeEnum
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the sorting direction based on the previous direction.
|
||||
*
|
||||
* @param direction The previous sorting direction
|
||||
* @return The new sorting direction
|
||||
*/
|
||||
const toggleDirection = (direction: SortModeEnum) => {
|
||||
switch (direction) {
|
||||
case SortModeEnum.no:
|
||||
return SortModeEnum.up
|
||||
case SortModeEnum.up:
|
||||
return SortModeEnum.down
|
||||
case SortModeEnum.down:
|
||||
default:
|
||||
return SortModeEnum.no
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a button to change the sorting order of a list.
|
||||
*
|
||||
* @param children The children elements that should be rendered inside the button
|
||||
* @param variant The variant of the button
|
||||
* @param onDirectionChange Callback that is fired when the sorting direction is changed
|
||||
* @param direction The sorting direction that is used
|
||||
*/
|
||||
export const SortButton: React.FC<SortButtonProps> = ({ children, onDirectionChange, direction }) => {
|
||||
const toggleSort = useCallback(() => {
|
||||
onDirectionChange(toggleDirection(direction))
|
||||
}, [direction, onDirectionChange])
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (direction) {
|
||||
case SortModeEnum.down:
|
||||
return IconSortAlphaDown
|
||||
case SortModeEnum.up:
|
||||
return IconSortAlphaUp
|
||||
case SortModeEnum.no:
|
||||
return IconX
|
||||
}
|
||||
}, [direction])
|
||||
|
||||
return (
|
||||
<IconButton onClick={toggleSort} variant={'secondary'} icon={icon} iconSize={1.5} border={true}>
|
||||
{children}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Hook that returns the title of a note in the history if present or the translation for "untitled" otherwise.
|
||||
*
|
||||
* @param entry The history entry containing a title property, that might be an empty string.
|
||||
* @return A memoized string containing either the title of the entry or the translated version of "untitled".
|
||||
*/
|
||||
export const useHistoryEntryTitle = (entry: HistoryEntryWithOrigin): string => {
|
||||
const { t } = useTranslation()
|
||||
return useMemo(() => {
|
||||
return entry.title !== '' ? entry.title : t('editor.untitledNote')
|
||||
}, [t, entry])
|
||||
}
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
import type { HistoryToolbarState } from './history-toolbar/history-toolbar-state'
|
||||
import { SortModeEnum } from './sort-button/sort-button'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
/**
|
||||
* Parses a given ISO formatted date string and outputs it as a date and time string.
|
||||
*
|
||||
* @param date The date in ISO format.
|
||||
* @return The date formatted as date and time string.
|
||||
*/
|
||||
export const formatHistoryDate = (date: string): string => DateTime.fromISO(date).toFormat('DDDD T')
|
||||
|
||||
/**
|
||||
* Applies sorting and filter rules that match a given toolbar state to a list of history entries.
|
||||
*
|
||||
* @param entries The history entries to sort and filter.
|
||||
* @param toolbarState The state of the history toolbar (sorting rules, keyword and tag input).
|
||||
* @return The list of filtered and sorted history entries.
|
||||
*/
|
||||
export const sortAndFilterEntries = (
|
||||
entries: HistoryEntryWithOrigin[],
|
||||
toolbarState: HistoryToolbarState
|
||||
): HistoryEntryWithOrigin[] => {
|
||||
const filteredBySelectedTagsEntries = filterBySelectedTags(entries, toolbarState.selectedTags)
|
||||
const filteredByKeywordSearchEntries = filterByKeywordSearch(filteredBySelectedTagsEntries, toolbarState.search)
|
||||
return sortEntries(filteredByKeywordSearchEntries, toolbarState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the given history entries by the given tags.
|
||||
*
|
||||
* @param entries The history entries to filter.
|
||||
* @param selectedTags The tags that were selected as filter criteria.
|
||||
* @return The list of filtered history entries.
|
||||
*/
|
||||
const filterBySelectedTags = (entries: HistoryEntryWithOrigin[], selectedTags: string[]): HistoryEntryWithOrigin[] => {
|
||||
return entries.filter((entry) => {
|
||||
return selectedTags.length === 0 || arrayCommonCheck(entry.tags, selectedTags)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the entries of array 1 are contained in array 2.
|
||||
*
|
||||
* @param array1 The first input array.
|
||||
* @param array2 The second input array.
|
||||
* @return true if all entries from array 1 are contained in array 2, false otherwise.
|
||||
*/
|
||||
const arrayCommonCheck = <T>(array1: T[], array2: T[]): boolean => {
|
||||
const foundElement = array1.find((element1) => array2.find((element2) => element2 === element1))
|
||||
return !!foundElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the given history entries by the given search term. Works case-insensitive.
|
||||
*
|
||||
* @param entries The history entries to filter.
|
||||
* @param keywords The search term.
|
||||
* @return The history entries that contain the search term in their title.
|
||||
*/
|
||||
const filterByKeywordSearch = (entries: HistoryEntryWithOrigin[], keywords: string): HistoryEntryWithOrigin[] => {
|
||||
const searchTerm = keywords.toLowerCase()
|
||||
return entries.filter((entry) => entry.title.toLowerCase().includes(searchTerm))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the given history entries by the sorting rules of the provided toolbar state.
|
||||
*
|
||||
* @param entries The history entries to sort.
|
||||
* @param viewState The toolbar state containing the sorting options.
|
||||
* @return The sorted history entries.
|
||||
*/
|
||||
const sortEntries = (entries: HistoryEntryWithOrigin[], viewState: HistoryToolbarState): HistoryEntryWithOrigin[] => {
|
||||
return entries.sort((firstEntry, secondEntry) => {
|
||||
if (firstEntry.pinStatus && !secondEntry.pinStatus) {
|
||||
return -1
|
||||
}
|
||||
if (!firstEntry.pinStatus && secondEntry.pinStatus) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (viewState.titleSortDirection !== SortModeEnum.no) {
|
||||
return firstEntry.title.localeCompare(secondEntry.title) * viewState.titleSortDirection
|
||||
}
|
||||
|
||||
if (viewState.lastVisitedSortDirection !== SortModeEnum.no) {
|
||||
if (firstEntry.lastVisitedAt > secondEntry.lastVisitedAt) {
|
||||
return 1 * viewState.lastVisitedSortDirection
|
||||
}
|
||||
if (firstEntry.lastVisitedAt < secondEntry.lastVisitedAt) {
|
||||
return -1 * viewState.lastVisitedSortDirection
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
|
||||
/**
|
||||
* A button that links to the history page.
|
||||
*/
|
||||
export const HistoryButton: React.FC = () => {
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
<Link href={'/history'}>
|
||||
<Button variant={'secondary'} size={'sm'}>
|
||||
<Trans i18nKey='landing.navigation.history' />
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
|
@ -7,7 +7,6 @@
|
|||
import React from 'react'
|
||||
import { Card } from 'react-bootstrap'
|
||||
import { NewNoteButton } from '../../common/new-note-button/new-note-button'
|
||||
import { HistoryButton } from '../../layout/app-bar/app-bar-elements/help-dropdown/history-button'
|
||||
import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { PermissionLevel } from '@hedgedoc/commons'
|
||||
|
@ -32,7 +31,6 @@ export const GuestCard: React.FC = () => {
|
|||
</Card.Title>
|
||||
<div className={'d-flex flex-row gap-2'}>
|
||||
<NewNoteButton />
|
||||
<HistoryButton />
|
||||
</div>
|
||||
{guestAccessLevel !== PermissionLevel.CREATE && (
|
||||
<div className={'text-muted mt-2 small'}>
|
||||
|
|
|
@ -13,7 +13,7 @@ import { useMemo } from 'react'
|
|||
* @return True, if the current user is owner.
|
||||
*/
|
||||
export const useIsOwner = (): boolean => {
|
||||
const me: string | undefined = useApplicationState((state) => state.user?.username)
|
||||
const me: string | null | undefined = useApplicationState((state) => state.user?.username)
|
||||
const permissions = useApplicationState((state) => state.noteDetails?.permissions)
|
||||
|
||||
return useMemo(() => (permissions === undefined ? false : userIsOwner(permissions, me)), [permissions, me])
|
||||
|
|
|
@ -13,7 +13,7 @@ import { useMemo } from 'react'
|
|||
* @return True, if the current user is allowed to write.
|
||||
*/
|
||||
export const useMayEdit = (): boolean => {
|
||||
const me: string | undefined = useApplicationState((state) => state.user?.username)
|
||||
const me: string | undefined | null = useApplicationState((state) => state.user?.username)
|
||||
const permissions = useApplicationState((state) => state.noteDetails?.permissions)
|
||||
|
||||
return useMemo(() => (!permissions ? false : userCanEdit(permissions, me)), [permissions, me])
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntry } from '../../../../api/history/types'
|
||||
import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request'
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
respondToMatchingRequest<HistoryEntry[]>(HttpMethod.GET, req, res, [
|
||||
{
|
||||
identifier: 'slide-example',
|
||||
title: 'Slide example',
|
||||
lastVisitedAt: '2020-05-30T15:20:36.088Z',
|
||||
pinStatus: true,
|
||||
tags: ['features', 'cool', 'updated'],
|
||||
owner: null
|
||||
},
|
||||
{
|
||||
identifier: 'features',
|
||||
title: 'Features',
|
||||
lastVisitedAt: '2020-05-31T15:20:36.088Z',
|
||||
pinStatus: true,
|
||||
tags: ['features', 'cool', 'updated'],
|
||||
owner: null
|
||||
},
|
||||
{
|
||||
identifier: 'ODakLc2MQkyyFc_Xmb53sg',
|
||||
title: 'Non existent',
|
||||
lastVisitedAt: '2020-05-25T19:48:14.025Z',
|
||||
pinStatus: false,
|
||||
tags: [],
|
||||
owner: null
|
||||
},
|
||||
{
|
||||
identifier: 'l8JuWxApTR6Fqa0LCrpnLg',
|
||||
title: 'Non existent',
|
||||
lastVisitedAt: '2020-05-24T16:04:36.433Z',
|
||||
pinStatus: false,
|
||||
tags: ['agenda', 'HedgeDoc community', 'community call'],
|
||||
owner: 'test'
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
export default handler
|
|
@ -14,7 +14,6 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
metadata: {
|
||||
id: 'exampleId',
|
||||
version: 2,
|
||||
viewCount: 0,
|
||||
updatedAt: '2021-04-24T09:27:51.000Z',
|
||||
createdAt: '2021-04-24T09:27:51.000Z',
|
||||
lastUpdatedBy: null,
|
||||
|
@ -23,13 +22,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
title: 'Features',
|
||||
tags: ['hedgedoc', 'demo', 'react'],
|
||||
description: 'Many features, such wow!',
|
||||
aliases: [
|
||||
{
|
||||
name: 'features',
|
||||
primaryAlias: true,
|
||||
noteId: 'exampleId'
|
||||
}
|
||||
],
|
||||
aliases: ['features'],
|
||||
permissions: {
|
||||
owner: 'tilman',
|
||||
sharedToUsers: [
|
||||
|
|
|
@ -115,7 +115,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
edits: [],
|
||||
length: 2782,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 2,
|
||||
authorGuestUuids: ['1', '2', '3'],
|
||||
content: `---
|
||||
title: Features
|
||||
description: Many features, such wow!
|
||||
|
|
|
@ -63,7 +63,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
edits: [],
|
||||
length: 2788,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 4,
|
||||
authorGuestUuids: ['1', '2', '3'],
|
||||
content: `---
|
||||
title: Features
|
||||
description: Many more features, such wow!
|
||||
|
|
|
@ -14,7 +14,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
createdAt: '2021-12-29T17:54:11.000Z',
|
||||
length: 2788,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 4,
|
||||
authorGuestUuids: ['1', '2', '3'],
|
||||
title: 'Features',
|
||||
description: 'Many features, such wow!',
|
||||
tags: ['hedgedoc', 'demo', 'react']
|
||||
|
@ -24,7 +24,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
createdAt: '2021-12-21T16:59:42.000Z',
|
||||
length: 2782,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 2,
|
||||
authorGuestUuids: ['1', '2', '3'],
|
||||
title: 'Features',
|
||||
description: 'Many more features, such wow!',
|
||||
tags: ['hedgedoc', 'demo', 'react']
|
||||
|
|
|
@ -17,7 +17,6 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
metadata: {
|
||||
id: 'featuresId',
|
||||
version: 2,
|
||||
viewCount: 0,
|
||||
updatedAt: '2021-04-24T09:27:51.000Z',
|
||||
createdAt: '2021-04-24T09:27:51.000Z',
|
||||
lastUpdatedBy: null,
|
||||
|
@ -26,13 +25,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
title: 'New note',
|
||||
tags: ['hedgedoc', 'demo', 'react'],
|
||||
description: 'Many features, such wow!',
|
||||
aliases: [
|
||||
{
|
||||
name: 'features',
|
||||
primaryAlias: true,
|
||||
noteId: 'featuresId'
|
||||
}
|
||||
],
|
||||
aliases: ['features'],
|
||||
permissions: {
|
||||
owner: 'tilman',
|
||||
sharedToUsers: [
|
||||
|
|
|
@ -15,7 +15,6 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
id: 'slideId',
|
||||
primaryAlias: 'slide-example',
|
||||
version: 2,
|
||||
viewCount: 8,
|
||||
updatedAt: '2021-04-30T18:38:23.000Z',
|
||||
lastUpdatedBy: null,
|
||||
createdAt: '2021-04-30T18:38:14.000Z',
|
||||
|
@ -23,13 +22,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse): void => {
|
|||
title: 'Slide example',
|
||||
tags: [],
|
||||
description: '',
|
||||
aliases: [
|
||||
{
|
||||
noteId: 'slideId',
|
||||
primaryAlias: true,
|
||||
name: 'slide-example'
|
||||
}
|
||||
],
|
||||
aliases: ['slideId'],
|
||||
permissions: {
|
||||
owner: 'erik',
|
||||
sharedToUsers: [
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryState } from './types'
|
||||
|
||||
export const initialState: HistoryState = []
|
|
@ -1,257 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
deleteRemoteHistory,
|
||||
deleteRemoteHistoryEntry,
|
||||
getRemoteHistory,
|
||||
setRemoteHistoryEntries,
|
||||
updateRemoteHistoryEntryPinStatus
|
||||
} from '../../api/history'
|
||||
import { addRemoteOriginToHistoryEntry, historyEntryToHistoryEntryPutDto } from '../../api/history/dto-methods'
|
||||
import type { HistoryEntry, HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
import { HistoryEntryOrigin } from '../../api/history/types'
|
||||
import { download } from '../../components/common/download/download'
|
||||
import { Logger } from '../../utils/logger'
|
||||
import { store } from '../index'
|
||||
import type { HistoryExportJson, V1HistoryEntry } from './types'
|
||||
import { DateTime } from 'luxon'
|
||||
import { historyActionsCreator } from './slice'
|
||||
|
||||
const log = new Logger('Redux > History')
|
||||
|
||||
/**
|
||||
* Sets the given history entries into the current redux state and updates the local-storage.
|
||||
* @param entries The history entries to set into the redux state.
|
||||
*/
|
||||
export const setHistoryEntries = (entries: HistoryEntryWithOrigin[]): void => {
|
||||
const action = historyActionsCreator.setEntries(entries)
|
||||
store.dispatch(action)
|
||||
storeLocalHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the given history entries into redux state and local-storage and remote based on their associated origin label.
|
||||
* @param entries The history entries to import.
|
||||
*/
|
||||
export const importHistoryEntries = (entries: HistoryEntryWithOrigin[]): Promise<unknown> => {
|
||||
setHistoryEntries(entries)
|
||||
return storeRemoteHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all history entries in the redux, local-storage and on the server.
|
||||
*/
|
||||
export const deleteAllHistoryEntries = (): Promise<unknown> => {
|
||||
const action = historyActionsCreator.setEntries([])
|
||||
store.dispatch(action)
|
||||
storeLocalHistory()
|
||||
return deleteRemoteHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single history entry in the redux.
|
||||
* @param noteId The note id of the history entry to update.
|
||||
* @param newEntry The modified history entry.
|
||||
*/
|
||||
export const updateHistoryEntryRedux = (noteId: string, newEntry: HistoryEntry): void => {
|
||||
const action = historyActionsCreator.updateEntry({
|
||||
noteId,
|
||||
newEntry
|
||||
})
|
||||
store.dispatch(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single history entry in the local-storage.
|
||||
* @param noteId The note id of the history entry to update.
|
||||
* @param newEntry The modified history entry.
|
||||
*/
|
||||
export const updateLocalHistoryEntry = (noteId: string, newEntry: HistoryEntry): void => {
|
||||
updateHistoryEntryRedux(noteId, newEntry)
|
||||
storeLocalHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single history entry for a given note id.
|
||||
* @param noteId The note id of the history entry to delete.
|
||||
*/
|
||||
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 deleteRemoteHistoryEntry(noteId)
|
||||
}
|
||||
const action = historyActionsCreator.removeEntry({ noteId })
|
||||
store.dispatch(action)
|
||||
storeLocalHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the pinning state of a single history entry.
|
||||
* @param noteId The note id of the history entry to update.
|
||||
*/
|
||||
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(new Error(`History entry for note '${noteId}' not found`))
|
||||
}
|
||||
const updatedEntry = {
|
||||
...entryToUpdate,
|
||||
pinStatus: !entryToUpdate.pinStatus
|
||||
}
|
||||
if (entryToUpdate.origin === HistoryEntryOrigin.LOCAL) {
|
||||
updateLocalHistoryEntry(noteId, updatedEntry)
|
||||
} else {
|
||||
await updateRemoteHistoryEntryPinStatus(noteId, updatedEntry.pinStatus)
|
||||
updateHistoryEntryRedux(noteId, updatedEntry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the current history redux state into a JSON file that will be downloaded by the client.
|
||||
*/
|
||||
export const downloadHistory = (): void => {
|
||||
const history = store.getState().history
|
||||
history.forEach((entry: Partial<HistoryEntryWithOrigin>) => {
|
||||
delete entry.origin
|
||||
})
|
||||
const json = JSON.stringify({
|
||||
version: 2,
|
||||
entries: history
|
||||
} as HistoryExportJson)
|
||||
download(json, `history_${Date.now()}.json`, 'application/json')
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two arrays of history entries while removing duplicates.
|
||||
* @param a The first input array of history entries.
|
||||
* @param b The second input array of history entries. This array takes precedence when duplicates were found.
|
||||
* @return The merged array of history entries without duplicates.
|
||||
*/
|
||||
export const mergeHistoryEntries = (
|
||||
a: HistoryEntryWithOrigin[],
|
||||
b: HistoryEntryWithOrigin[]
|
||||
): HistoryEntryWithOrigin[] => {
|
||||
const noDuplicates = a.filter((entryA) => !b.some((entryB) => entryA.identifier === entryB.identifier))
|
||||
return noDuplicates.concat(b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of local HedgeDoc v1 history entries to HedgeDoc v2 history entries.
|
||||
* @param oldHistory An array of HedgeDoc v1 history entries.
|
||||
* @return An array of HedgeDoc v2 history entries associated with the local origin label.
|
||||
*/
|
||||
export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntryWithOrigin[] => {
|
||||
return oldHistory.map((entry) => ({
|
||||
identifier: entry.id,
|
||||
title: entry.text,
|
||||
tags: entry.tags,
|
||||
lastVisitedAt: DateTime.fromMillis(entry.time).toISO(),
|
||||
pinStatus: entry.pinned,
|
||||
origin: HistoryEntryOrigin.LOCAL,
|
||||
owner: null
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the history redux state by reloading the local history and fetching the remote history if the user is logged-in.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the history entries marked as local from the redux to the user's local-storage.
|
||||
*/
|
||||
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
|
||||
}))
|
||||
try {
|
||||
window.localStorage.setItem('history', JSON.stringify(entriesWithoutOrigin))
|
||||
} catch (error) {
|
||||
log.error("Can't save history", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the history entries marked as remote from the redux to the server.
|
||||
*/
|
||||
export const storeRemoteHistory = (): Promise<unknown> => {
|
||||
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 setRemoteHistoryEntries(remoteEntryDtos)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the local history from local-storage, converts from V1 format if necessary and returns the history entries with a local origin label.
|
||||
* @return The local history entries with the origin set to local.
|
||||
*/
|
||||
const loadLocalHistory = (): HistoryEntryWithOrigin[] => {
|
||||
const localV1Json = readV1HistoryEntriesFromLocalStorage()
|
||||
if (localV1Json) {
|
||||
try {
|
||||
const localV1History = JSON.parse(JSON.parse(localV1Json) as string) as V1HistoryEntry[]
|
||||
window.localStorage.removeItem('notehistory')
|
||||
return convertV1History(localV1History)
|
||||
} catch (error) {
|
||||
log.error('Error while converting old history entries', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const localJson = window.localStorage.getItem('history')
|
||||
if (!localJson) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const localHistory = JSON.parse(localJson) as HistoryEntryWithOrigin[]
|
||||
localHistory.forEach((entry) => {
|
||||
entry.origin = HistoryEntryOrigin.LOCAL
|
||||
})
|
||||
return localHistory
|
||||
} catch (error) {
|
||||
log.error('Error while parsing locally stored history entries', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const readV1HistoryEntriesFromLocalStorage = () => {
|
||||
try {
|
||||
return window.localStorage.getItem('notehistory')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the remote history and maps each entry with a remote origin label.
|
||||
* @return The remote history entries with the origin set to remote.
|
||||
*/
|
||||
const loadRemoteHistory = async (): Promise<HistoryEntryWithOrigin[]> => {
|
||||
try {
|
||||
const remoteHistory = await getRemoteHistory()
|
||||
return remoteHistory.map(addRemoteOriginToHistoryEntry)
|
||||
} catch (error) {
|
||||
log.error('Error while fetching history entries from server', error)
|
||||
return []
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import { initialState } from './initial-state'
|
||||
import type { HistoryState, RemoveEntryPayload, UpdateEntryPayload } from './types'
|
||||
import type { HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
|
||||
const historySlice = createSlice({
|
||||
name: 'history',
|
||||
initialState,
|
||||
reducers: {
|
||||
setEntries: (state, action: PayloadAction<HistoryState>) => {
|
||||
return action.payload
|
||||
},
|
||||
updateEntry: (state, action: PayloadAction<UpdateEntryPayload>) => {
|
||||
const entryToUpdateIndex = state.findIndex((entry) => entry.identifier === action.payload.noteId)
|
||||
if (entryToUpdateIndex < 0) {
|
||||
return state
|
||||
}
|
||||
const updatedEntry: HistoryEntryWithOrigin = { ...state[entryToUpdateIndex], ...action.payload.newEntry }
|
||||
return state.toSpliced(entryToUpdateIndex, 1, updatedEntry)
|
||||
},
|
||||
removeEntry: (state, action: PayloadAction<RemoveEntryPayload>) => {
|
||||
return state.filter((entry) => entry.identifier !== action.payload.noteId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const historyActionsCreator = historySlice.actions
|
||||
export const historyReducer = historySlice.reducer
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { HistoryEntry, HistoryEntryWithOrigin } from '../../api/history/types'
|
||||
|
||||
export type HistoryState = HistoryEntryWithOrigin[]
|
||||
|
||||
export interface V1HistoryEntry {
|
||||
id: string
|
||||
text: string
|
||||
time: number
|
||||
tags: string[]
|
||||
pinned: boolean
|
||||
}
|
||||
|
||||
export interface HistoryExportJson {
|
||||
version: number
|
||||
entries: HistoryEntryWithOrigin[]
|
||||
}
|
||||
|
||||
export interface UpdateEntryPayload {
|
||||
noteId: string
|
||||
newEntry: HistoryEntry
|
||||
}
|
||||
|
||||
export interface RemoveEntryPayload {
|
||||
noteId: string
|
||||
}
|
|
@ -10,7 +10,6 @@ import { editorConfigReducer } from './editor-config/slice'
|
|||
import { userReducer } from './user/slice'
|
||||
import { rendererStatusReducer } from './renderer-status/slice'
|
||||
import { realtimeStatusReducer } from './realtime/slice'
|
||||
import { historyReducer } from './history/slice'
|
||||
import { noteDetailsReducer } from './note-details/slice'
|
||||
import { printModeReducer } from './print-mode/slice'
|
||||
|
||||
|
@ -21,7 +20,6 @@ export const store = configureStore({
|
|||
user: userReducer,
|
||||
rendererStatus: rendererStatusReducer,
|
||||
realtimeStatus: realtimeStatusReducer,
|
||||
history: historyReducer,
|
||||
noteDetails: noteDetailsReducer,
|
||||
printMode: printModeReducer
|
||||
},
|
||||
|
|
|
@ -22,11 +22,9 @@ describe('build state from server permissions', () => {
|
|||
primaryAlias: 'test-id',
|
||||
tags: ['test'],
|
||||
description: 'test',
|
||||
id: 'test-id',
|
||||
aliases: [],
|
||||
title: 'test',
|
||||
version: 2,
|
||||
viewCount: 42,
|
||||
createdAt: '2022-09-18T18:51:00.000+02:00',
|
||||
updatedAt: '2022-09-18T18:52:00.000+02:00'
|
||||
}
|
||||
|
@ -40,11 +38,9 @@ describe('build state from server permissions', () => {
|
|||
},
|
||||
editedBy: [],
|
||||
primaryAlias: 'test-id',
|
||||
id: 'test-id',
|
||||
aliases: [],
|
||||
title: 'test',
|
||||
version: 2,
|
||||
viewCount: 42,
|
||||
createdAt: 1663519860,
|
||||
updatedAt: 1663519920
|
||||
})
|
||||
|
|
|
@ -34,14 +34,7 @@ describe('build state from set note data from server', () => {
|
|||
metadata: {
|
||||
primaryAlias: 'alias',
|
||||
version: 5678,
|
||||
aliases: [
|
||||
{
|
||||
noteId: 'id',
|
||||
primaryAlias: true,
|
||||
name: 'alias'
|
||||
}
|
||||
],
|
||||
id: 'id',
|
||||
aliases: ['alias'],
|
||||
createdAt: '2012-05-25T09:08:34.123',
|
||||
description: 'description',
|
||||
editedBy: ['editedBy'],
|
||||
|
@ -60,7 +53,6 @@ describe('build state from set note data from server', () => {
|
|||
}
|
||||
]
|
||||
},
|
||||
viewCount: 987,
|
||||
tags: ['tag'],
|
||||
title: 'title',
|
||||
updatedAt: '2020-05-25T09:08:34.123',
|
||||
|
@ -107,18 +99,10 @@ describe('build state from set note data from server', () => {
|
|||
},
|
||||
firstHeading: '',
|
||||
rawFrontmatter: '',
|
||||
id: 'id',
|
||||
createdAt: DateTime.fromISO('2012-05-25T09:08:34.123').toSeconds(),
|
||||
updatedAt: DateTime.fromISO('2020-05-25T09:08:34.123').toSeconds(),
|
||||
lastUpdatedBy: 'updateusername',
|
||||
viewCount: 987,
|
||||
aliases: [
|
||||
{
|
||||
name: 'alias',
|
||||
noteId: 'id',
|
||||
primaryAlias: true
|
||||
}
|
||||
],
|
||||
aliases: ['alias'],
|
||||
primaryAlias: 'alias',
|
||||
version: 5678,
|
||||
editedBy: ['editedBy'],
|
||||
|
|
|
@ -34,7 +34,6 @@ export const mockAppState = (state?: DeepPartial<ApplicationState>) => {
|
|||
...initialStateEditorConfig,
|
||||
...state?.editorConfig
|
||||
},
|
||||
history: [], // Yes this allows no mocking and is therefore technically not correct, but the type is difficult to fix and we will remove it soon anyway.
|
||||
noteDetails: {
|
||||
...initialStateNoteDetails,
|
||||
...state?.noteDetails
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue