diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index b9085859d..44bc1e5e3 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -395,6 +395,14 @@
"contributors": "Count of contributors",
"wordCount": "Count of words"
},
+ "mediaBrowser": {
+ "title": "Media",
+ "deleteMedia": "Delete uploaded file",
+ "confirmDeletion": "Do you really want to delete this file?",
+ "errorDeleting": "The uploaded file could not be deleted.",
+ "mediaDeleted": "The uploaded file has been deleted.",
+ "noMediaUploads": "There are no media files uploaded to this note yet"
+ },
"modal": {
"snippetImport": {
"title": "Import from Snippet",
@@ -553,6 +561,7 @@
"loading": "Loading ...",
"continue": "Continue",
"back": "Back",
+ "success": "Success",
"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/common/modals/__snapshots__/deletion-modal.spec.tsx.snap b/frontend/src/components/common/modals/__snapshots__/deletion-modal.spec.tsx.snap
index d17361f43..b84f09096 100644
--- a/frontend/src/components/common/modals/__snapshots__/deletion-modal.spec.tsx.snap
+++ b/frontend/src/components/common/modals/__snapshots__/deletion-modal.spec.tsx.snap
@@ -1,47 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`DeletionModal disables deletion when user is not owner 1`] = `
-
-`;
-
exports[`DeletionModal renders correctly with deletionButtonI18nKey 1`] = `
{
const modal = await screen.findByTestId('commonModal')
expect(modal).toMatchSnapshot()
})
-
- it('disables deletion when user is not owner', async () => {
- mockNotePermissions('test2', 'test')
- const onConfirm = jest.fn()
- render(
-
- testText
-
- )
- const modal = await screen.findByTestId('commonModal')
- expect(modal).toMatchSnapshot()
- })
})
diff --git a/frontend/src/components/common/modals/deletion-modal.tsx b/frontend/src/components/common/modals/deletion-modal.tsx
index 5cd5a9386..96900c99a 100644
--- a/frontend/src/components/common/modals/deletion-modal.tsx
+++ b/frontend/src/components/common/modals/deletion-modal.tsx
@@ -1,9 +1,8 @@
/*
- * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
+ * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { useIsOwner } from '../../../hooks/common/use-is-owner'
import { cypressId } from '../../../utils/cypress-attribute'
import type { CommonModalProps } from './common-modal'
import { CommonModal } from './common-modal'
@@ -15,6 +14,7 @@ import { Trans, useTranslation } from 'react-i18next'
export interface DeletionModalProps extends CommonModalProps {
onConfirm: () => void
deletionButtonI18nKey: string
+ disabled?: boolean
}
/**
@@ -38,11 +38,10 @@ export const DeletionModal: React.FC
> = ({
deletionButtonI18nKey,
titleIcon,
children,
+ disabled = false,
...props
}) => {
useTranslation()
- const isOwner = useIsOwner()
-
return (
> = ({
{...props}>
{children}
-
+
diff --git a/frontend/src/components/editor-page/sidebar/sidebar.tsx b/frontend/src/components/editor-page/sidebar/sidebar.tsx
index e0a7c060b..657983eb4 100644
--- a/frontend/src/components/editor-page/sidebar/sidebar.tsx
+++ b/frontend/src/components/editor-page/sidebar/sidebar.tsx
@@ -7,6 +7,7 @@ import { AliasesSidebarEntry } from './specific-sidebar-entries/aliases-sidebar-
import { DeleteNoteSidebarEntry } from './specific-sidebar-entries/delete-note-sidebar-entry/delete-note-sidebar-entry'
import { ExportSidebarMenu } from './specific-sidebar-entries/export-sidebar-menu/export-sidebar-menu'
import { ImportMenuSidebarMenu } from './specific-sidebar-entries/import-menu-sidebar-menu'
+import { MediaBrowserSidebarMenu } from './specific-sidebar-entries/media-browser-sidebar-menu/media-browser-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'
import { PinNoteSidebarEntry } from './specific-sidebar-entries/pin-note-sidebar-entry/pin-note-sidebar-entry'
@@ -57,6 +58,11 @@ export const Sidebar: React.FC = () => {
+
{
const noteTitle = useNoteTitle()
+ const isOwner = useIsOwner()
return (
diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-empty.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-empty.tsx
new file mode 100644
index 000000000..914a12495
--- /dev/null
+++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-empty.tsx
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import React from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+
+/**
+ * Renders the info message for the media browser empty state.
+ */
+export const MediaBrowserEmpty: React.FC = () => {
+ useTranslation()
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-sidebar-menu.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-sidebar-menu.tsx
new file mode 100644
index 000000000..ebc892838
--- /dev/null
+++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-browser-sidebar-menu.tsx
@@ -0,0 +1,79 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { SidebarButton } from '../../sidebar-button/sidebar-button'
+import { SidebarMenu } from '../../sidebar-menu/sidebar-menu'
+import type { SpecificSidebarMenuProps } from '../../types'
+import { DocumentSidebarMenuSelection } from '../../types'
+import React, { Fragment, useCallback, useMemo, useState } from 'react'
+import { ArrowLeft as IconArrowLeft, Images as IconImages } from 'react-bootstrap-icons'
+import { Trans, useTranslation } from 'react-i18next'
+import { useAsync } from 'react-use'
+import { useApplicationState } from '../../../../../hooks/common/use-application-state'
+import { getMediaForNote } from '../../../../../api/notes'
+import { AsyncLoadingBoundary } from '../../../../common/async-loading-boundary/async-loading-boundary'
+import { MediaEntry } from './media-entry'
+import type { MediaUpload } from '../../../../../api/media/types'
+import { MediaEntryDeletionModal } from './media-entry-deletion-modal'
+import { MediaBrowserEmpty } from './media-browser-empty'
+
+/**
+ * Renders the media browser "menu" for the sidebar.
+ *
+ * @param className Additional class names given to the menu button
+ * @param menuId The id of the menu
+ * @param onClick The callback, that should be called when the menu button is pressed
+ * @param selectedMenuId The currently selected menu id
+ */
+export const MediaBrowserSidebarMenu: React.FC = ({
+ className,
+ menuId,
+ onClick,
+ selectedMenuId
+}) => {
+ useTranslation()
+ const noteId = useApplicationState((state) => state.noteDetails?.id ?? '')
+ const [mediaEntryForDeletion, setMediaEntryForDeletion] = useState(null)
+
+ const hide = selectedMenuId !== DocumentSidebarMenuSelection.NONE && selectedMenuId !== menuId
+ const expand = selectedMenuId === menuId
+ const onClickHandler = useCallback(() => {
+ onClick(menuId)
+ }, [menuId, onClick])
+
+ const { value, loading, error } = useAsync(() => getMediaForNote(noteId), [expand, noteId])
+
+ const mediaEntries = useMemo(() => {
+ if (loading || error || !value) {
+ return []
+ }
+ return value.map((entry) => )
+ }, [value, loading, error, setMediaEntryForDeletion])
+
+ const cancelDeletion = useCallback(() => {
+ setMediaEntryForDeletion(null)
+ }, [])
+
+ return (
+
+
+
+
+
+
+ {mediaEntries}
+ {mediaEntries.length === 0 && }
+
+
+ {mediaEntryForDeletion && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-entry-deletion-modal.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-entry-deletion-modal.tsx
new file mode 100644
index 000000000..5e13fcd81
--- /dev/null
+++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-entry-deletion-modal.tsx
@@ -0,0 +1,46 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import React, { useCallback } from 'react'
+import type { MediaEntryProps } from './media-entry'
+import type { ModalVisibilityProps } from '../../../../common/modals/common-modal'
+import { DeletionModal } from '../../../../common/modals/deletion-modal'
+import { deleteUploadedMedia } from '../../../../../api/media'
+import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
+import { Trans, useTranslation } from 'react-i18next'
+
+type MediaEntryDeletionModalProps = Pick & ModalVisibilityProps
+
+/**
+ * Renders a modal for confirming the deletion of a media entry.
+ *
+ * @param entry The media entry to delete
+ * @param show Whether the modal should be shown
+ * @param onHide The callback when the modal should be hidden
+ */
+export const MediaEntryDeletionModal: React.FC = ({ entry, show, onHide }) => {
+ useTranslation()
+ const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
+
+ const handleDelete = useCallback(() => {
+ deleteUploadedMedia(entry.id)
+ .then(() => {
+ dispatchUiNotification('common.success', 'editor.mediaBrowser.mediaDeleted', {})
+ })
+ .catch(showErrorNotification('editor.mediaBrowser.errorDeleting'))
+ .finally(onHide)
+ }, [showErrorNotification, dispatchUiNotification, entry, onHide])
+
+ return (
+
+
+
+ )
+}
diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-entry.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-entry.tsx
new file mode 100644
index 000000000..8add73701
--- /dev/null
+++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-entry.tsx
@@ -0,0 +1,93 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import React, { useCallback, useMemo } from 'react'
+import type { MediaUpload } from '../../../../../api/media/types'
+import { useBaseUrl } from '../../../../../hooks/common/use-base-url'
+import { Button, ButtonGroup } from 'react-bootstrap'
+import {
+ Trash as IconTrash,
+ FileRichtextFill as IconFileRichtextFill,
+ Person as IconPerson,
+ Clock as IconClock
+} from 'react-bootstrap-icons'
+import { useIsOwner } from '../../../../../hooks/common/use-is-owner'
+import { useApplicationState } from '../../../../../hooks/common/use-application-state'
+import { UserAvatarForUsername } from '../../../../common/user-avatar/user-avatar-for-username'
+import { useChangeEditorContentCallback } from '../../../change-content-context/use-change-editor-content-callback'
+import { replaceSelection } from '../../../editor-pane/tool-bar/formatters/replace-selection'
+
+export interface MediaEntryProps {
+ entry: MediaUpload
+ onDelete: (entry: MediaUpload) => void
+}
+
+/**
+ * Renders a single media entry in the media browser.
+ *
+ * @param entry The media entry to render
+ * @param onDelete The callback to call when the entry should be deleted
+ */
+export const MediaEntry: React.FC = ({ entry, onDelete }) => {
+ const changeEditorContent = useChangeEditorContentCallback()
+ const user = useApplicationState((state) => state.user?.username)
+ const baseUrl = useBaseUrl()
+ const isOwner = useIsOwner()
+
+ const imageUrl = useMemo(() => {
+ return `${baseUrl}api/private/media/${entry.id}`
+ }, [entry, baseUrl])
+ const textCreatedTime = useMemo(() => {
+ return new Date(entry.createdAt).toLocaleString()
+ }, [entry])
+
+ const handleInsertIntoNote = useCallback(() => {
+ changeEditorContent?.(({ currentSelection }) => {
+ return replaceSelection(
+ { from: currentSelection.to ?? currentSelection.from },
+ `![${entry.id}](${imageUrl})`,
+ true
+ )
+ })
+ }, [changeEditorContent, entry, imageUrl])
+
+ const deleteEntry = useCallback(() => {
+ onDelete(entry)
+ }, [entry, onDelete])
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/editor-page/sidebar/types.ts b/frontend/src/components/editor-page/sidebar/types.ts
index 7d01f3620..b406ad640 100644
--- a/frontend/src/components/editor-page/sidebar/types.ts
+++ b/frontend/src/components/editor-page/sidebar/types.ts
@@ -30,6 +30,7 @@ export enum DocumentSidebarMenuSelection {
NONE,
USERS_ONLINE,
NOTE_INFO,
+ MEDIA_BROWSER,
IMPORT,
EXPORT
}