diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b7884a0c0..b9085859d 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -333,6 +333,37 @@ } }, "export": { + "common": { + "title": "Export to {{service}}", + "createButton": "Create {{shortName}}", + "headingAuthentication": "Authentication", + "headingSettings": "Export settings", + "fieldToken": "Access token", + "fieldDescription": "Description", + "notificationSuccessTitle": "{{shortName}} successfully created", + "notificationSuccessMessage": "The note has been exported to {{service}}.", + "notificationSuccessButton": "Open {{shortName}}", + "notificationErrorTitle": "Error while creating {{shortName}}: {{errorMessage}}" + }, + "gist": { + "service": "GitHub Gist", + "shortName": "Gist", + "infoToken": "You need a personal access token with the Gists account permission. You can create one in your GitHub settings here:", + "fieldPublic": "Make Gist public?" + }, + "gitlab": { + "service": "GitLab snippet", + "shortName": "Snippet", + "infoToken": "You need a personal access token with the api permission. You can create one in your GitLab user settings here:", + "infoTokenLink": "Preferences → Access tokens", + "fieldUrl": "GitLab URL", + "fieldVisibility": "Set snippet visibility", + "visibility": { + "private": "Private", + "internal": "Internal", + "public": "Public" + } + }, "rawHtml": "Raw HTML", "markdown-file": "Markdown file" }, @@ -520,6 +551,8 @@ "avatarOf": "Avatar of '{{name}}'", "why": "Why?", "loading": "Loading ...", + "continue": "Continue", + "back": "Back", "errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.", "errorOccurred": "An error occurred", "readForMoreInfo": "Read here for more information", diff --git a/frontend/src/components/editor-page/sidebar/sidebar.tsx b/frontend/src/components/editor-page/sidebar/sidebar.tsx index c050d61b3..e0a7c060b 100644 --- a/frontend/src/components/editor-page/sidebar/sidebar.tsx +++ b/frontend/src/components/editor-page/sidebar/sidebar.tsx @@ -5,7 +5,7 @@ */ import { AliasesSidebarEntry } from './specific-sidebar-entries/aliases-sidebar-entry/aliases-sidebar-entry' import { DeleteNoteSidebarEntry } from './specific-sidebar-entries/delete-note-sidebar-entry/delete-note-sidebar-entry' -import { ExportMenuSidebarMenu } from './specific-sidebar-entries/export-menu-sidebar-menu' +import { ExportSidebarMenu } from './specific-sidebar-entries/export-sidebar-menu/export-sidebar-menu' import { ImportMenuSidebarMenu } from './specific-sidebar-entries/import-menu-sidebar-menu' import { NoteInfoSidebarMenu } from './specific-sidebar-entries/note-info-sidebar-menu/note-info-sidebar-menu' import { PermissionsSidebarEntry } from './specific-sidebar-entries/permissions-sidebar-entry/permissions-sidebar-entry' @@ -62,7 +62,7 @@ export const Sidebar: React.FC = () => { selectedMenuId={selectedMenu} onClick={toggleValue} /> - => { + const response = await fetch('https://api.github.com/gists', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + description: description, + public: isPublic, + files: { + [fileName]: { content } + } + }) + }) + if (!response.ok) { + if (response.status === 401) { + throw new Error('Invalid GitHub token provided') + } + throw new Error('Request to GitHub API failed') + } + const json = (await response.json()) as { html_url: string } + if (!json.html_url) { + throw new Error('Invalid response from GitHub API') + } + return json.html_url +} diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/export-gist-modal.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/export-gist-modal.tsx new file mode 100644 index 000000000..486d2acd3 --- /dev/null +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/export-gist-modal.tsx @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { useCallback, useMemo, useState } from 'react' +import type { ModalVisibilityProps } from '../../../../../../common/modals/common-modal' +import { CommonModal } from '../../../../../../common/modals/common-modal' +import { Button, FormCheck, FormControl, FormGroup, FormLabel, FormText, Modal } from 'react-bootstrap' +import { useNoteMarkdownContent } from '../../../../../../../hooks/common/use-note-markdown-content' +import { useNoteFilename } from '../../../../../../../hooks/common/use-note-filename' +import { useOnInputChange } from '../../../../../../../hooks/common/use-on-input-change' +import { Github } from 'react-bootstrap-icons' +import { useUiNotifications } from '../../../../../../notifications/ui-notification-boundary' +import { useTranslatedText } from '../../../../../../../hooks/common/use-translated-text' +import { Trans, useTranslation } from 'react-i18next' +import { ExternalLink } from '../../../../../../common/links/external-link' +import { createGist } from './create-gist' +import { validateToken } from './validate-token' + +/** + * Renders the modal for exporting the note content to a GitHub Gist. + * + * @param show true to show the modal, false otherwise. + * @param onHide Callback that is fired when the modal is about to be closed. + */ +export const ExportGistModal: React.FC = ({ show, onHide }) => { + useTranslation() + + const noteContent = useNoteMarkdownContent() + const noteFilename = useNoteFilename() + + const { dispatchUiNotification, showErrorNotification } = useUiNotifications() + + const textService = useTranslatedText('editor.export.gist.service') + const textShortName = useTranslatedText('editor.export.gist.shortName') + const textModalTitle = useTranslatedText('editor.export.common.title', { replace: { service: textService } }) + const textCreateButton = useTranslatedText('editor.export.common.createButton', { + replace: { shortName: textShortName } + }) + const textNotificationButton = useTranslatedText('editor.export.common.notificationSuccessButton', { + replace: { + shortName: textShortName + } + }) + const textFieldPublic = useTranslatedText('editor.export.gist.fieldPublic') + + const [ghToken, setGhToken] = useState('') + const [gistDescription, setGistDescription] = useState('') + const [gistPublic, setGistPublic] = useState(false) + + const onGistDescriptionChange = useOnInputChange(setGistDescription) + const onGhTokenChange = useOnInputChange(setGhToken) + const onGistPublicChange = useCallback(() => setGistPublic((prev) => !prev), []) + + const ghTokenFormatValid = useMemo(() => validateToken(ghToken), [ghToken]) + + const onCreateGist = useCallback(() => { + createGist(ghToken, noteContent, gistDescription, noteFilename, gistPublic) + .then((gistUrl) => { + dispatchUiNotification( + 'editor.export.common.notificationSuccessTitle', + 'editor.export.common.notificationSuccessMessage', + { + durationInSecond: 30, + icon: Github, + buttons: [{ label: textNotificationButton, onClick: () => window.open(gistUrl, '_blank') }], + titleI18nOptions: { + replace: { shortName: textShortName } + }, + contentI18nOptions: { + replace: { service: textService } + } + } + ) + onHide?.() + }) + .catch( + showErrorNotification( + 'editor.export.common.notificationErrorTitle', + { replace: { shortName: textShortName } }, + true + ) + ) + }, [ + ghToken, + noteContent, + noteFilename, + gistDescription, + gistPublic, + showErrorNotification, + textShortName, + dispatchUiNotification, + textNotificationButton, + textService, + onHide + ]) + + return ( + + +
+ +
+ + + + + + + {' '} + + + +
+ +
+ + + + + + + + + +
+ + + +
+ ) +} diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/export-gist-sidebar-entry.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/export-gist-sidebar-entry.tsx new file mode 100644 index 000000000..39144bae9 --- /dev/null +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/export-gist-sidebar-entry.tsx @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { Fragment } from 'react' +import { Github as IconGithub } from 'react-bootstrap-icons' +import { SidebarButton } from '../../../../sidebar-button/sidebar-button' +import { ExportGistModal } from './export-gist-modal' +import { useBooleanState } from '../../../../../../../hooks/common/use-boolean-state' +import { Trans, useTranslation } from 'react-i18next' + +/** + * Renders the sidebar entry for exporting the note content to a GitHub Gist. + */ +export const ExportGistSidebarEntry: React.FC = () => { + useTranslation() + const [showModal, setShowModal, setHideModal] = useBooleanState(false) + + return ( + + + + + + + ) +} diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/validate-token.spec.ts b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/validate-token.spec.ts new file mode 100644 index 000000000..af9720ab5 --- /dev/null +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/validate-token.spec.ts @@ -0,0 +1,39 @@ +import { validateToken } from './validate-token' + +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +describe('GitHub validateToken', () => { + it('should return true for a valid classic token', () => { + // token is randomly generated and not real + const token = 'ghp_yQbWDPxQgaZAeTSuVFJrmVGloUfENbFqzLHS' + expect(validateToken(token)).toBe(true) + }) + it('should return true for a valid scoped token', () => { + // token is randomly generated and not real + const token = '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' + expect(validateToken(token)).toBe(true) + }) + it('should return false for an empty token', () => { + const token = '' + expect(validateToken(token)).toBe(false) + }) + it('should return false for an invalid classic token', () => { + const token = 'ghp_wrong_length' + expect(validateToken(token)).toBe(false) + }) + it('should return false for an invalid scoped token (1)', () => { + const token = 'github_pat_wrong_length' + expect(validateToken(token)).toBe(false) + }) + it('should return false for an invalid scoped token (2)', () => { + const token = 'github_pat_wrongwronmgwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongwrongw' + expect(validateToken(token)).toBe(false) + }) + it('should return false for a token with an invalid prefix', () => { + const token = 'wrong_abc' + expect(validateToken(token)).toBe(false) + }) +}) diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/validate-token.ts b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/validate-token.ts new file mode 100644 index 000000000..ee09cad43 --- /dev/null +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gist-sidebar-entry/validate-token.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +const GITHUB_CLASSIC_TOKEN_LENGTH = 40 +const GITHUB_CLASSIC_TOKEN_PREFIX = 'ghp_' +const GITHUB_SCOPED_TOKEN_LENGTH = 93 +const GITHUB_SCOPED_TOKEN_PREFIX = 'github_pat_' + +/** + * Validates a given input to the format of a GitHub personal access token. + * + * @param token The input to validate + * @return true if the input conforms to the format of a GitHub personal access token, false otherwise + */ +export const validateToken = (token: string): boolean => { + return ( + (token.startsWith(GITHUB_CLASSIC_TOKEN_PREFIX) && token.length === GITHUB_CLASSIC_TOKEN_LENGTH) || + (token.startsWith(GITHUB_SCOPED_TOKEN_PREFIX) && + token.length === GITHUB_SCOPED_TOKEN_LENGTH && + token.charAt(33) === '_') + ) +} diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gitlab-snippet-sidebar-entry/create-snippet.ts b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gitlab-snippet-sidebar-entry/create-snippet.ts new file mode 100644 index 000000000..7914d109d --- /dev/null +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gitlab-snippet-sidebar-entry/create-snippet.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export enum GitlabSnippetVisibility { + PRIVATE = 'private', + INTERNAL = 'internal', + PUBLIC = 'public' +} + +/** + * Creates a snippet on a GitLab instance with the given content. + * + * @param instanceUrl The URL of the GitLab instance + * @param token The GitLab access token to use + * @param content The content of the snippet + * @param title The title of the snippet + * @param description The description of the gist + * @param fileName The (generated) file name of the note + * @param visibility The visibility level of the snippet + * @return The URL of the created snippet + */ +export const createSnippet = async ( + instanceUrl: string, + token: string, + content: string, + title: string, + description: string, + fileName: string, + visibility: GitlabSnippetVisibility +): Promise => { + const cleanedInstanceUrl = instanceUrl.endsWith('/') ? instanceUrl.slice(0, -1) : instanceUrl + + const response = await fetch(`${cleanedInstanceUrl}/api/v4/snippets`, { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: title, + description: description, + visibility: visibility, + files: [ + { + content: content, + file_path: fileName + } + ] + }) + }) + if (!response.ok) { + if (response.status === 401) { + throw new Error('Invalid GitLab access token provided') + } + throw new Error('Request to GitLab API failed') + } + const json = (await response.json()) as { web_url: string } + if (!json.web_url) { + throw new Error('Invalid response from GitLab API') + } + return json.web_url +} diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gitlab-snippet-sidebar-entry/export-gitlab-snippet-modal.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gitlab-snippet-sidebar-entry/export-gitlab-snippet-modal.tsx new file mode 100644 index 000000000..8efa9e7a5 --- /dev/null +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gitlab-snippet-sidebar-entry/export-gitlab-snippet-modal.tsx @@ -0,0 +1,172 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { useCallback, useMemo, useState } from 'react' +import type { ModalVisibilityProps } from '../../../../../../common/modals/common-modal' +import { CommonModal } from '../../../../../../common/modals/common-modal' +import { Trans, useTranslation } from 'react-i18next' +import { useTranslatedText } from '../../../../../../../hooks/common/use-translated-text' +import { IconGitlab } from '../../../../../../common/icons/additional/icon-gitlab' +import { useOnInputChange } from '../../../../../../../hooks/common/use-on-input-change' +import { Button, FormControl, FormGroup, FormLabel, FormText, Modal } from 'react-bootstrap' +import { ExternalLink } from '../../../../../../common/links/external-link' +import { useNoteMarkdownContent } from '../../../../../../../hooks/common/use-note-markdown-content' +import { useNoteFilename } from '../../../../../../../hooks/common/use-note-filename' +import { useUiNotifications } from '../../../../../../notifications/ui-notification-boundary' +import { createSnippet, GitlabSnippetVisibility } from './create-snippet' +import { Github } from 'react-bootstrap-icons' +import { useNoteTitle } from '../../../../../../../hooks/common/use-note-title' + +/** + * Renders the modal for exporting the note content to a GitLab snippet. + * + * @param show true to show the modal, false otherwise. + * @param onHide Callback that is fired when the modal is about to be closed. + */ +export const ExportGitlabSnippetModal: React.FC = ({ show, onHide }) => { + useTranslation() + + const noteContent = useNoteMarkdownContent() + const noteFilename = useNoteFilename() + const noteTitle = useNoteTitle() + + const { dispatchUiNotification, showErrorNotification } = useUiNotifications() + + const [gitlabUrl, setGitlabUrl] = useState('') + const [gitlabToken, setGitlabToken] = useState('') + const [snippetVisibility, setSnippetVisibility] = useState(GitlabSnippetVisibility.PRIVATE) + const [snippetDescription, setSnippetDescription] = useState('') + + const textService = useTranslatedText('editor.export.gitlab.service') + const textShortName = useTranslatedText('editor.export.gitlab.shortName') + const textModalTitle = useTranslatedText('editor.export.common.title', { replace: { service: textService } }) + const textTokenLink = useTranslatedText('editor.export.gitlab.infoTokenLink') + const textCreateButton = useTranslatedText('editor.export.common.createButton', { + replace: { shortName: textShortName } + }) + const textNotificationButton = useTranslatedText('editor.export.common.notificationSuccessButton', { + replace: { + shortName: textShortName + } + }) + + const changeGitlabUrl = useOnInputChange(setGitlabUrl) + const changeGitlabToken = useOnInputChange(setGitlabToken) + const changeSnippetVisibility = useCallback((event: React.ChangeEvent) => { + setSnippetVisibility(event.target.value as GitlabSnippetVisibility) + }, []) + const changeSnippetDescription = useOnInputChange(setSnippetDescription) + + const creationPossible = useMemo(() => { + return gitlabUrl !== '' && gitlabToken !== '' + }, [gitlabUrl, gitlabToken]) + + const onCreateSnippet = useCallback(() => { + createSnippet(gitlabUrl, gitlabToken, noteContent, noteTitle, snippetDescription, noteFilename, snippetVisibility) + .then((snippetUrl) => { + dispatchUiNotification( + 'editor.export.common.notificationSuccessTitle', + 'editor.export.common.notificationSuccessMessage', + { + durationInSecond: 30, + icon: Github, + buttons: [{ label: textNotificationButton, onClick: () => window.open(snippetUrl, '_blank') }], + titleI18nOptions: { + replace: { shortName: textShortName } + }, + contentI18nOptions: { + replace: { service: textService } + } + } + ) + onHide?.() + }) + .catch( + showErrorNotification( + 'editor.export.common.notificationErrorTitle', + { replace: { shortName: textShortName } }, + true + ) + ) + }, [ + gitlabUrl, + gitlabToken, + noteContent, + noteTitle, + snippetDescription, + noteFilename, + snippetVisibility, + showErrorNotification, + textShortName, + dispatchUiNotification, + textNotificationButton, + textService, + onHide + ]) + + return ( + + +
+ +
+ + + + + + + + + + + + + {' '} + + + +
+ +
+ + + + + + + + + + + + + + + + +
+ + + +
+ ) +} diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gitlab-snippet-sidebar-entry/export-gitlab-snippet-sidebar-entry.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gitlab-snippet-sidebar-entry/export-gitlab-snippet-sidebar-entry.tsx new file mode 100644 index 000000000..69b99d388 --- /dev/null +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-gitlab-snippet-sidebar-entry/export-gitlab-snippet-sidebar-entry.tsx @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { Fragment } from 'react' +import { SidebarButton } from '../../../../sidebar-button/sidebar-button' +import { useBooleanState } from '../../../../../../../hooks/common/use-boolean-state' +import { Trans, useTranslation } from 'react-i18next' +import { IconGitlab } from '../../../../../../common/icons/additional/icon-gitlab' +import { ExportGitlabSnippetModal } from './export-gitlab-snippet-modal' + +/** + * Renders the sidebar entry for exporting the note content to a GitLab snippet. + */ +export const ExportGitlabSnippetSidebarEntry: React.FC = () => { + useTranslation() + const [showModal, setShowModal, setHideModal] = useBooleanState(false) + + return ( + + + + + + + ) +} diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-markdown-sidebar-entry.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-markdown-sidebar-entry.tsx similarity index 50% rename from frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-markdown-sidebar-entry.tsx rename to frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-markdown-sidebar-entry.tsx index 0f4bf28c9..800425d88 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-markdown-sidebar-entry.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/entries/export-markdown-sidebar-entry.tsx @@ -1,32 +1,31 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -import { useNoteMarkdownContent } from '../../../../hooks/common/use-note-markdown-content' -import { getGlobalState } from '../../../../redux' -import { cypressId } from '../../../../utils/cypress-attribute' -import { download } from '../../../common/download/download' -import { SidebarButton } from '../sidebar-button/sidebar-button' +import { useNoteMarkdownContent } from '../../../../../../hooks/common/use-note-markdown-content' +import { getGlobalState } from '../../../../../../redux' +import { cypressId } from '../../../../../../utils/cypress-attribute' +import { download } from '../../../../../common/download/download' +import { SidebarButton } from '../../../sidebar-button/sidebar-button' import React, { useCallback } from 'react' import { FileText as IconFileText } from 'react-bootstrap-icons' -import { Trans, useTranslation } from 'react-i18next' -import sanitize from 'sanitize-filename' +import { Trans } from 'react-i18next' +import { useNoteFilename } from '../../../../../../hooks/common/use-note-filename' /** * Editor sidebar entry for exporting the markdown content into a local file. */ export const ExportMarkdownSidebarEntry: React.FC = () => { - const { t } = useTranslation() const markdownContent = useNoteMarkdownContent() + const fileName = useNoteFilename() const onClick = useCallback(() => { const title = getGlobalState().noteDetails?.title if (title === undefined) { return } - const sanitized = sanitize(title) - download(markdownContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown') - }, [markdownContent, t]) + download(markdownContent, fileName, 'text/markdown') + }, [markdownContent, fileName]) return ( diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-menu-sidebar-menu.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/export-sidebar-menu.tsx similarity index 63% rename from frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-menu-sidebar-menu.tsx rename to frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/export-sidebar-menu.tsx index db5f6ae69..d6b928d83 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-menu-sidebar-menu.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/export-sidebar-menu/export-sidebar-menu.tsx @@ -3,23 +3,23 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { cypressId } from '../../../../utils/cypress-attribute' -import { IconGitlab } from '../../../common/icons/additional/icon-gitlab' -import { SidebarButton } from '../sidebar-button/sidebar-button' -import { SidebarMenu } from '../sidebar-menu/sidebar-menu' -import type { SpecificSidebarMenuProps } from '../types' -import { DocumentSidebarMenuSelection } from '../types' -import { ExportMarkdownSidebarEntry } from './export-markdown-sidebar-entry' +import { cypressId } from '../../../../../utils/cypress-attribute' +import { SidebarButton } from '../../sidebar-button/sidebar-button' +import { SidebarMenu } from '../../sidebar-menu/sidebar-menu' +import type { SpecificSidebarMenuProps } from '../../types' +import { DocumentSidebarMenuSelection } from '../../types' +import { ExportMarkdownSidebarEntry } from './entries/export-markdown-sidebar-entry' import React, { Fragment, useCallback } from 'react' import { ArrowLeft as IconArrowLeft, CloudDownload as IconCloudDownload, - FileCode as IconFileCode, - Github as IconGithub + FileCode as IconFileCode } from 'react-bootstrap-icons' import { Trans, useTranslation } from 'react-i18next' -import { concatCssClasses } from '../../../../utils/concat-css-classes' -import styles from '../sidebar-button/sidebar-button.module.scss' +import { concatCssClasses } from '../../../../../utils/concat-css-classes' +import styles from '../../sidebar-button/sidebar-button.module.scss' +import { ExportGistSidebarEntry } from './entries/export-gist-sidebar-entry/export-gist-sidebar-entry' +import { ExportGitlabSnippetSidebarEntry } from './entries/export-gitlab-snippet-sidebar-entry/export-gitlab-snippet-sidebar-entry' /** * Renders the export menu for the sidebar. @@ -29,7 +29,7 @@ import styles from '../sidebar-button/sidebar-button.module.scss' * @param onClick The callback, that should be called when the menu button is pressed * @param selectedMenuId The currently selected menu id */ -export const ExportMenuSidebarMenu: React.FC = ({ +export const ExportSidebarMenu: React.FC = ({ className, menuId, onClick, @@ -53,13 +53,8 @@ export const ExportMenuSidebarMenu: React.FC = ({ - - Gist - - - Gitlab Snippet - - + + diff --git a/frontend/src/hooks/common/use-note-filename.tsx b/frontend/src/hooks/common/use-note-filename.tsx new file mode 100644 index 000000000..531c6b094 --- /dev/null +++ b/frontend/src/hooks/common/use-note-filename.tsx @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useMemo } from 'react' +import { useNoteTitle } from './use-note-title' +import sanitize from 'sanitize-filename' + +/** + * Returns a sanitized filename for the current note based on the title. + * When no title is provided, the filename will be "untitled". + * + * @return The sanitized filename + */ +export const useNoteFilename = (): string => { + const title = useNoteTitle() + return useMemo(() => `${sanitize(title)}.md`, [title]) +}