diff --git a/package.json b/package.json index 30abb4276..d20c31003 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "react-bootstrap": "1.3.0", "react-bootstrap-typeahead": "5.1.1", "react-codemirror2": "7.2.1", + "react-diff-viewer": "^3.1.1", "react-dom": "16.13.1", "react-html-parser": "2.0.2", "react-i18next": "11.7.2", diff --git a/public/api/v2/notes/features/revisions-list b/public/api/v2/notes/features/revisions-list new file mode 100644 index 000000000..e89b4e765 --- /dev/null +++ b/public/api/v2/notes/features/revisions-list @@ -0,0 +1,12 @@ +[ + { + "timestamp": 1598390307, + "length": 2788, + "authors": ["dermolly", "mrdrogdrog"] + }, + { + "timestamp": 1598389571, + "length": 2782, + "authors": ["dermolly", "mrdrogdrog", "emcrx"] + } +] diff --git a/public/api/v2/notes/features/revisions/1598389571 b/public/api/v2/notes/features/revisions/1598389571 new file mode 100644 index 000000000..6fa22b220 --- /dev/null +++ b/public/api/v2/notes/features/revisions/1598389571 @@ -0,0 +1,5 @@ +{ + "content": "---\ntitle: Features\ndescription: Many features, such wow!\nrobots: noindex\ntags: codimd, demo, react\nopengraph:\n title: Features\n---\n# Embedding demo\n[TOC]\n\n## some plain text\n\nLorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.\n\n## MathJax\nYou can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https:\/\/math.stackexchange.com\/):\n\nThe *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral\n\n$$\nx = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}.\n$$\n\n$$\n\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,.\n$$\n\n> More information about **LaTeX** mathematical expressions [here](https:\/\/meta.math.stackexchange.com\/questions\/5020\/mathjax-basic-tutorial-and-quick-reference).\n\n## Blockquote\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> [color=red] [name=John Doe] [time=2020-06-21 22:50]\n\n## Slideshare\n{%slideshare mazlan1\/internet-of-things-the-tip-of-an-iceberg %}\n\n## Gist\nhttps:\/\/gist.github.com\/schacon\/1\n\n## YouTube\nhttps:\/\/www.youtube.com\/watch?v=KgMpKsp23yY\n\n## Vimeo\nhttps:\/\/vimeo.com\/23237102\n\n## Asciinema\nhttps:\/\/asciinema.org\/a\/117928\n\n## PDF\n{%pdf https:\/\/www.w3.org\/WAI\/ER\/tests\/xhtml\/testfiles\/resources\/pdf\/dummy.pdf %}\n\n## Code highlighting\n```javascript=\n\nlet a = 1\n```\n\n## PlantUML\n```plantuml\n@startuml\nparticipant Alice\nparticipant \"The **Famous** Bob\" as Bob\n\nAlice -> Bob : hello --there--\n... Some ~~long delay~~ ...\nBob -> Alice : ok\nnote left\n This is **bold**\n This is \/\/italics\/\/\n This is \"\"monospaced\"\"\n This is --stroked--\n This is __underlined__\n This is ~~waved~~\nend note\n\nAlice -> Bob : A \/\/well formatted\/\/ message\nnote right of Alice\n This is displayed<\/size><\/back>\n __left of__ Alice.\nend note\nnote left of Bob\n This<\/u> is displayed<\/color>\n **left of<\/color> Alice<\/strike> Bob**.\nend note\nnote over Alice, Bob\n This is hosted<\/w> by \nend note\n@enduml\n```\n\n", + "timestamp": 1598389571, + "authors": ["mrdrogdrog", "dermolly", "emcrx"] +} diff --git a/public/api/v2/notes/features/revisions/1598390307 b/public/api/v2/notes/features/revisions/1598390307 new file mode 100644 index 000000000..6b814a8d5 --- /dev/null +++ b/public/api/v2/notes/features/revisions/1598390307 @@ -0,0 +1,5 @@ +{ + "content": "---\ntitle: Features\ndescription: Many more features, such wow!\nrobots: noindex\ntags: codimd, demo, react\nopengraph:\n title: Features\n---\n# Embedding demo\n[TOC]\n\n## some plain text\n\nLorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magnus aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetezur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam _et_ justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.\n\n## MathJax\nYou can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https:\/\/math.stackexchange.com\/):\n\nThe *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral\n\n$$\nx = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}.\n$$\n\n$$\n\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,.\n$$\n\n> More information about **LaTeX** mathematical expressions [here](https:\/\/meta.math.stackexchange.com\/questions\/5020\/mathjax-basic-tutorial-and-quick-reference).\n\n## Blockquote\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> [color=red] [name=John Doe] [time=2020-06-21 22:50]\n\n## Slideshare\n{%slideshare mazlan1\/internet-of-things-the-tip-of-an-iceberg %}\n\n## Gist\nhttps:\/\/gist.github.com\/schacon\/1\n\n## YouTube\nhttps:\/\/www.youtube.com\/watch?v=zHAIuE5BQWk\n\n## Vimeo\nhttps:\/\/vimeo.com\/23237102\n\n## Asciinema\nhttps:\/\/asciinema.org\/a\/117928\n\n## PDF\n{%pdf https:\/\/www.w3.org\/WAI\/ER\/tests\/xhtml\/testfiles\/resources\/pdf\/dummy.pdf %}\n\n## Code highlighting\n```javascript=\n\nlet a = 1\n```\n\n## PlantUML\n```plantuml\n@startuml\nparticipant Alice\nparticipant \"The **Famous** Bob\" as Bob\n\nAlice -> Bob : bye --there--\n... Some ~~long delay~~ ...\nBob -> Alice : ok\nnote left\n This is **bold**\n This is \/\/italics\/\/\n This is \"\"monospaced\"\"\n This is --stroked--\n This is __underlined__\n This is ~~waved~~\nend note\n\nAlice -> Bob : A \/\/well formatted\/\/ message\nnote right of Alice\n This is displayed<\/size><\/back>\n __left of__ Alice.\nend note\nnote left of Bob\n This<\/u> is displayed<\/color>\n **left of<\/color> Alice<\/strike> Bob**.\nend note\nnote over Alice, Bob\n This is hosted<\/w> by \nend note\n@enduml\n```\n\n", + "timestamp": 1598390307, + "authors": ["mrdrogdrog", "dermolly"] +} diff --git a/public/api/v2/users/dermolly b/public/api/v2/users/dermolly new file mode 100644 index 000000000..60dd3d4b2 --- /dev/null +++ b/public/api/v2/users/dermolly @@ -0,0 +1,7 @@ +{ + "id": "dermolly", + "photo": "/avatar.png", + "name": "Philip", + "status": "ok", + "provider": "internal" +} diff --git a/public/api/v2/users/emcrx b/public/api/v2/users/emcrx new file mode 100644 index 000000000..16d83df3d --- /dev/null +++ b/public/api/v2/users/emcrx @@ -0,0 +1,7 @@ +{ + "id": "emcrx", + "photo": "/avatar.png", + "name": "Erik", + "status": "ok", + "provider": "internal" +} diff --git a/public/api/v2/users/mrdrogdrog b/public/api/v2/users/mrdrogdrog new file mode 100644 index 000000000..05c7863c4 --- /dev/null +++ b/public/api/v2/users/mrdrogdrog @@ -0,0 +1,7 @@ +{ + "id": "mrdrogdrog", + "photo": "/avatar.png", + "name": "Tilman", + "status": "ok", + "provider": "internal" +} diff --git a/public/locales/en.json b/public/locales/en.json index d8f12925f..3086af88b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -303,8 +303,11 @@ "visibilityLevel": "Select Visibility Level" }, "revision": { - "title": "Revision", - "revertButton": "Revert" + "title": "Revisions", + "revertButton": "Revert", + "error": "An error occurred while fetching the revisions of this note.", + "length": "Length", + "download": "Download selected revision" }, "clipboardImport": { "title": "Import from clipboard", diff --git a/src/api/me/index.ts b/src/api/me/index.ts index 4b2706678..b310de105 100644 --- a/src/api/me/index.ts +++ b/src/api/me/index.ts @@ -1,19 +1,12 @@ -import { LoginProvider } from '../../redux/user/types' +import { UserResponse } from '../users/types' import { expectResponseCode, getApiUrl, defaultFetchConfig } from '../utils' -export const getMe = async (): Promise => { +export const getMe = async (): Promise => { const response = await fetch(getApiUrl() + '/me', { ...defaultFetchConfig }) expectResponseCode(response) - return (await response.json()) as meResponse -} - -export interface meResponse { - id: string - name: string - photo: string - provider: LoginProvider + return (await response.json()) as UserResponse } export const updateDisplayName = async (displayName: string): Promise => { diff --git a/src/api/revisions/index.ts b/src/api/revisions/index.ts new file mode 100644 index 000000000..746e85f17 --- /dev/null +++ b/src/api/revisions/index.ts @@ -0,0 +1,30 @@ +import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' + +export interface Revision { + content: string + timestamp: number + authors: string[] +} + +export interface RevisionListEntry { + timestamp: number + length: number + authors: string[] +} + +export const getRevision = async (noteId: string, timestamp: number): Promise => { + const response = await fetch(getApiUrl() + `/notes/${noteId}/revisions/${timestamp}`, { + ...defaultFetchConfig + }) + expectResponseCode(response) + return await response.json() as Promise +} + +export const getAllRevisions = async (noteId: string): Promise => { + // TODO Change 'revisions-list' to 'revisions' as soon as the backend is ready to serve some data! + const response = await fetch(getApiUrl() + `/notes/${noteId}/revisions-list`, { + ...defaultFetchConfig + }) + expectResponseCode(response) + return await response.json() as Promise +} diff --git a/src/api/users/index.ts b/src/api/users/index.ts new file mode 100644 index 000000000..98e640345 --- /dev/null +++ b/src/api/users/index.ts @@ -0,0 +1,10 @@ +import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' +import { UserResponse } from './types' + +export const getUserById = async (userid: string): Promise => { + const response = await fetch(`${getApiUrl()}/users/${userid}`, { + ...defaultFetchConfig + }) + expectResponseCode(response) + return (await response.json()) as UserResponse +} diff --git a/src/api/users/types.d.ts b/src/api/users/types.d.ts new file mode 100644 index 000000000..c155ff711 --- /dev/null +++ b/src/api/users/types.d.ts @@ -0,0 +1,8 @@ +import { LoginProvider } from '../../redux/user/types' + +export interface UserResponse { + id: string + name: string + photo: string + provider: LoginProvider +} diff --git a/src/components/common/modals/common-modal.tsx b/src/components/common/modals/common-modal.tsx index ba5b0929e..e97388a3b 100644 --- a/src/components/common/modals/common-modal.tsx +++ b/src/components/common/modals/common-modal.tsx @@ -11,13 +11,15 @@ export interface CommonModalProps { titleI18nKey: string closeButton?: boolean icon?: IconName + size?: 'lg' | 'sm' | 'xl' + additionalClasses?: string } -export const CommonModal: React.FC = ({ show, onHide, titleI18nKey, closeButton, icon, children }) => { +export const CommonModal: React.FC = ({ show, onHide, titleI18nKey, closeButton, icon, additionalClasses, size, children }) => { useTranslation() return ( - + diff --git a/src/components/common/user-avatar/user-avatar.tsx b/src/components/common/user-avatar/user-avatar.tsx index 7c8036b38..64b4f73ce 100644 --- a/src/components/common/user-avatar/user-avatar.tsx +++ b/src/components/common/user-avatar/user-avatar.tsx @@ -19,6 +19,7 @@ const UserAvatar: React.FC = ({ name, photo, additionalClasses src={photo} className="user-avatar rounded" alt={t('common.avatarOf', { name })} + title={name} /> {name} diff --git a/src/components/editor/document-bar/buttons/revision-button.tsx b/src/components/editor/document-bar/buttons/revision-button.tsx deleted file mode 100644 index 823dd2bf7..000000000 --- a/src/components/editor/document-bar/buttons/revision-button.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react' -import { TranslatedIconButton } from '../../../common/icon-button/translated-icon-button' - -export const RevisionButton: React.FC = () => { - return -} diff --git a/src/components/editor/document-bar/document-bar.tsx b/src/components/editor/document-bar/document-bar.tsx index ef85b40d0..e73f67fc9 100644 --- a/src/components/editor/document-bar/document-bar.tsx +++ b/src/components/editor/document-bar/document-bar.tsx @@ -8,13 +8,14 @@ import { ImportMenu } from './menus/import-menu' import { PermissionButton } from './buttons/permission-button' import { PinToHistoryButton } from './buttons/pin-to-history-button' import { ShareLinkButton } from './buttons/share-link-button' -import { RevisionButton } from './buttons/revision-button' +import { RevisionButton } from './revisions/revision-button' export interface DocumentBarProps { title: string + noteContent: string } -export const DocumentBar: React.FC = ({ title }) => { +export const DocumentBar: React.FC = ({ title, noteContent }) => { useTranslation() return ( @@ -22,7 +23,7 @@ export const DocumentBar: React.FC = ({ title }) => {
- +
diff --git a/src/components/editor/document-bar/revisions/revision-button.tsx b/src/components/editor/document-bar/revisions/revision-button.tsx new file mode 100644 index 000000000..0372509e7 --- /dev/null +++ b/src/components/editor/document-bar/revisions/revision-button.tsx @@ -0,0 +1,18 @@ +import React, { Fragment, useState } from 'react' +import { TranslatedIconButton } from '../../../common/icon-button/translated-icon-button' +import { RevisionModal } from './revision-modal' + +export interface RevisionButtonProps { + noteContent: string +} + +export const RevisionButton: React.FC = ({ noteContent }) => { + const [show, setShow] = useState(false) + + return ( + + setShow(true)}/> + setShow(false)} titleI18nKey={'editor.modal.revision.title'} icon={'history'} noteContent={noteContent}/> + + ) +} diff --git a/src/components/editor/document-bar/revisions/revision-modal-list-entry.tsx b/src/components/editor/document-bar/revisions/revision-modal-list-entry.tsx new file mode 100644 index 000000000..75eec4b34 --- /dev/null +++ b/src/components/editor/document-bar/revisions/revision-modal-list-entry.tsx @@ -0,0 +1,43 @@ +import moment from 'moment' +import React from 'react' +import { ListGroup } from 'react-bootstrap' +import { Trans } from 'react-i18next' +import { RevisionListEntry } from '../../../../api/revisions' +import { UserResponse } from '../../../../api/users/types' +import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' +import { UserAvatar } from '../../../common/user-avatar/user-avatar' + +export interface RevisionModalListEntryProps { + active: boolean + onClick: () => void + revision: RevisionListEntry + revisionAuthorListMap: Map +} + +export const RevisionModalListEntry: React.FC = ({ active, onClick, revision, revisionAuthorListMap }) => ( + + + + {moment(revision.timestamp * 1000).format('LLLL')} + + + + : {revision.length} + + + + { + revisionAuthorListMap.get(revision.timestamp)?.map((user, index) => { + return ( + + ) + }) + } + + +) diff --git a/src/components/editor/document-bar/revisions/revision-modal.scss b/src/components/editor/document-bar/revisions/revision-modal.scss new file mode 100644 index 000000000..9e57a1d6c --- /dev/null +++ b/src/components/editor/document-bar/revisions/revision-modal.scss @@ -0,0 +1,12 @@ +.revision-modal .row .scroll-col { + max-height: 75vh; + overflow-y: auto; +} + +li.revision-item { + cursor: pointer; + + span > img { + height: 1.25rem; + } +} diff --git a/src/components/editor/document-bar/revisions/revision-modal.tsx b/src/components/editor/document-bar/revisions/revision-modal.tsx new file mode 100644 index 000000000..8c9031ba4 --- /dev/null +++ b/src/components/editor/document-bar/revisions/revision-modal.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Alert, Col, ListGroup, Modal, Row, Button } from 'react-bootstrap' +import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer' +import { Trans, useTranslation } from 'react-i18next' +import { useParams } from 'react-router' +import { getAllRevisions, getRevision, Revision, RevisionListEntry } from '../../../../api/revisions' +import { UserResponse } from '../../../../api/users/types' +import { CommonModal, CommonModalProps } from '../../../common/modals/common-modal' +import { ShowIf } from '../../../common/show-if/show-if' +import { RevisionButtonProps } from './revision-button' +import { RevisionModalListEntry } from './revision-modal-list-entry' +import './revision-modal.scss' +import { downloadRevision, getUserDataForRevision } from './utils' + +export const RevisionModal: React.FC = ({ show, onHide, icon, titleI18nKey, noteContent }) => { + useTranslation() + const [revisions, setRevisions] = useState([]) + const [selectedRevisionTimestamp, setSelectedRevisionTimestamp] = useState(null) + const [selectedRevision, setSelectedRevision] = useState(null) + const [error, setError] = useState(false) + const revisionAuthorListMap = useRef(new Map()) + const revisionCacheMap = useRef(new Map()) + const { id } = useParams<{ id: string }>() + + useEffect(() => { + getAllRevisions(id).then(fetchedRevisions => { + fetchedRevisions.forEach(revision => { + const authorData = getUserDataForRevision(revision.authors) + revisionAuthorListMap.current.set(revision.timestamp, authorData) + }) + setRevisions(fetchedRevisions) + if (fetchedRevisions.length >= 1) { + setSelectedRevisionTimestamp(fetchedRevisions[0].timestamp) + } + }).catch(() => setError(true)) + }, [setRevisions, setError, id]) + + useEffect(() => { + if (selectedRevisionTimestamp === null) { + return + } + const cacheEntry = revisionCacheMap.current.get(selectedRevisionTimestamp) + if (cacheEntry) { + setSelectedRevision(cacheEntry) + return + } + getRevision(id, selectedRevisionTimestamp).then(fetchedRevision => { + setSelectedRevision(fetchedRevision) + revisionCacheMap.current.set(selectedRevisionTimestamp, fetchedRevision) + }).catch(() => setError(true)) + }, [selectedRevisionTimestamp, id]) + + return ( + + + + + + { + revisions.map((revision, revisionIndex) => ( + setSelectedRevisionTimestamp(revision.timestamp)} + /> + )) + } + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/editor/document-bar/revisions/utils.ts b/src/components/editor/document-bar/revisions/utils.ts new file mode 100644 index 000000000..fc1440b1e --- /dev/null +++ b/src/components/editor/document-bar/revisions/utils.ts @@ -0,0 +1,39 @@ +import { Revision } from '../../../../api/revisions' +import { getUserById } from '../../../../api/users' +import { UserResponse } from '../../../../api/users/types' + +const userResponseCache = new Map() + +export const downloadRevision = (noteId: string, revision: Revision | null): void => { + if (!revision) { + return + } + const encoded = Buffer.from(revision.content).toString('base64') + const wrapper = document.createElement('a') + wrapper.download = `${noteId}-${revision.timestamp}.md` + wrapper.href = `data:text/markdown;charset=utf-8;base64,${encoded}` + document.body.appendChild(wrapper) + wrapper.click() + document.body.removeChild(wrapper) +} + +export const getUserDataForRevision = (authors: string[]): UserResponse[] => { + const users: UserResponse[] = [] + authors.forEach((author, index) => { + if (index > 9) { + return + } + const cacheEntry = userResponseCache.get(author) + if (cacheEntry) { + users.push(cacheEntry) + return + } + getUserById(author) + .then(userData => { + users.push(userData) + userResponseCache.set(author, userData) + }) + .catch((error) => console.error(error)) + }) + return users +} diff --git a/src/components/editor/editor.tsx b/src/components/editor/editor.tsx index 31f0429ab..6d5bbc6e0 100644 --- a/src/components/editor/editor.tsx +++ b/src/components/editor/editor.tsx @@ -113,7 +113,7 @@ export const Editor: React.FC = () => {
- + =0.0.4" -source-map@^0.5.0, source-map@^0.5.6: +source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=