feat(sidebar): add media browser

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2024-03-24 01:04:34 +01:00 committed by Philip Molares
parent 8693edbf6a
commit 6bb2452705
11 changed files with 265 additions and 61 deletions

View file

@ -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",

View file

@ -1,47 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DeletionModal disables deletion when user is not owner 1`] = `
<div
class="modal-dialog"
data-testid="commonModal"
>
<div
class="modal-content"
>
<div
class="modal-header"
>
<div
class="modal-title h4"
>
<span />
</div>
<button
aria-label="Close"
class="btn-close"
type="button"
/>
</div>
<div
class="modal-body"
>
testText
</div>
<div
class="modal-footer"
>
<button
class="btn btn-danger"
disabled=""
type="button"
>
testDeletionButton
</button>
</div>
</div>
</div>
`;
exports[`DeletionModal renders correctly with deletionButtonI18nKey 1`] = `
<div
class="modal-dialog"

View file

@ -1,5 +1,5 @@
/*
* 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
*/
@ -29,16 +29,4 @@ describe('DeletionModal', () => {
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(
<DeletionModal onConfirm={onConfirm} deletionButtonI18nKey={'testDeletionButton'} show={true}>
testText
</DeletionModal>
)
const modal = await screen.findByTestId('commonModal')
expect(modal).toMatchSnapshot()
})
})

View file

@ -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<PropsWithChildren<DeletionModalProps>> = ({
deletionButtonI18nKey,
titleIcon,
children,
disabled = false,
...props
}) => {
useTranslation()
const isOwner = useIsOwner()
return (
<CommonModal
show={show}
@ -53,7 +52,7 @@ export const DeletionModal: React.FC<PropsWithChildren<DeletionModalProps>> = ({
{...props}>
<Modal.Body>{children}</Modal.Body>
<Modal.Footer>
<Button {...cypressId('deletionModal.confirmButton')} variant='danger' onClick={onConfirm} disabled={!isOwner}>
<Button {...cypressId('deletionModal.confirmButton')} variant='danger' onClick={onConfirm} disabled={disabled}>
<Trans i18nKey={deletionButtonI18nKey} />
</Button>
</Modal.Footer>

View file

@ -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 = () => {
<RevisionSidebarEntry hide={selectionIsNotNone} />
<PermissionsSidebarEntry hide={selectionIsNotNone} />
<AliasesSidebarEntry hide={selectionIsNotNone} />
<MediaBrowserSidebarMenu
onClick={toggleValue}
selectedMenuId={selectedMenu}
menuId={DocumentSidebarMenuSelection.MEDIA_BROWSER}
/>
<ImportMenuSidebarMenu
menuId={DocumentSidebarMenuSelection.IMPORT}
selectedMenuId={selectedMenu}

View file

@ -1,5 +1,5 @@
/*
* 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
*/
@ -9,6 +9,7 @@ import type { ModalVisibilityProps } from '../../../../common/modals/common-moda
import { DeletionModal } from '../../../../common/modals/deletion-modal'
import React from 'react'
import { Trans } from 'react-i18next'
import { useIsOwner } from '../../../../../hooks/common/use-is-owner'
export interface DeleteHistoryNoteModalProps {
modalTitleI18nKey?: string
@ -45,6 +46,7 @@ export const DeleteNoteModal: React.FC<DeleteNoteModalProps & DeleteHistoryNoteM
modalButtonI18nKey
}) => {
const noteTitle = useNoteTitle()
const isOwner = useIsOwner()
return (
<DeletionModal
@ -53,6 +55,7 @@ export const DeleteNoteModal: React.FC<DeleteNoteModalProps & DeleteHistoryNoteM
deletionButtonI18nKey={modalButtonI18nKey ?? 'editor.modal.deleteNote.button'}
show={show}
onHide={onHide}
disabled={!isOwner}
titleI18nKey={modalTitleI18nKey ?? 'editor.modal.deleteNote.title'}>
<h5>
<Trans i18nKey={modalQuestionI18nKey ?? 'editor.modal.deleteNote.question'} />

View file

@ -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 (
<div className='text-center p-2'>
<p className='text-muted'>
<Trans i18nKey={'editor.mediaBrowser.noMediaUploads'} />
</p>
</div>
)
}

View file

@ -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<SpecificSidebarMenuProps> = ({
className,
menuId,
onClick,
selectedMenuId
}) => {
useTranslation()
const noteId = useApplicationState((state) => state.noteDetails?.id ?? '')
const [mediaEntryForDeletion, setMediaEntryForDeletion] = useState<MediaUpload | null>(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) => <MediaEntry entry={entry} key={entry.id} onDelete={setMediaEntryForDeletion} />)
}, [value, loading, error, setMediaEntryForDeletion])
const cancelDeletion = useCallback(() => {
setMediaEntryForDeletion(null)
}, [])
return (
<Fragment>
<SidebarButton
hide={hide}
icon={expand ? IconArrowLeft : IconImages}
className={className}
onClick={onClickHandler}>
<Trans i18nKey={'editor.mediaBrowser.title'} />
</SidebarButton>
<SidebarMenu expand={expand}>
<AsyncLoadingBoundary loading={loading} componentName={'MediaBrowserSidebarMenu'} error={error}>
{mediaEntries}
{mediaEntries.length === 0 && <MediaBrowserEmpty />}
</AsyncLoadingBoundary>
</SidebarMenu>
{mediaEntryForDeletion && (
<MediaEntryDeletionModal entry={mediaEntryForDeletion} show={true} onHide={cancelDeletion} />
)}
</Fragment>
)
}

View file

@ -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<MediaEntryProps, 'entry'> & 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<MediaEntryDeletionModalProps> = ({ 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 (
<DeletionModal
onConfirm={handleDelete}
deletionButtonI18nKey={'common.delete'}
show={show}
onHide={onHide}
titleI18nKey={'editor.mediaBrowser.deleteMedia'}>
<Trans i18nKey={'editor.mediaBrowser.confirmDeletion'} />
</DeletionModal>
)
}

View file

@ -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<MediaEntryProps> = ({ 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 (
<div className={'p-2 border-bottom border-opacity-50'}>
<a href={imageUrl} target={'_blank'} rel={'noreferrer'} className={'text-center d-block mb-2'}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageUrl} alt={`Upload ${entry.id}`} height={100} className={'mw-100'} />
</a>
<div className={'w-100 d-flex flex-row align-items-center justify-content-between'}>
<div>
<small className={'d-inline-flex flex-row align-items-center'}>
<IconPerson className={'me-1'} />
<UserAvatarForUsername username={entry.username} size={'sm'} />
</small>
<br />
<small>
<IconClock className={'me-1'} />
{textCreatedTime}
</small>
</div>
<ButtonGroup className={'my-2'}>
<Button size={'sm'} variant={'primary'} onClick={handleInsertIntoNote}>
<IconFileRichtextFill />
</Button>
<Button
size={'sm'}
variant={'danger'}
disabled={!isOwner && (!user || entry.username !== user)}
onClick={deleteEntry}>
<IconTrash />
</Button>
</ButtonGroup>
</div>
</div>
)
}

View file

@ -30,6 +30,7 @@ export enum DocumentSidebarMenuSelection {
NONE,
USERS_ONLINE,
NOTE_INFO,
MEDIA_BROWSER,
IMPORT,
EXPORT
}