mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-12-18 00:31:48 -05:00
feat(sidebar): add media browser
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
8693edbf6a
commit
6bb2452705
11 changed files with 265 additions and 61 deletions
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'} />
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -30,6 +30,7 @@ export enum DocumentSidebarMenuSelection {
|
|||
NONE,
|
||||
USERS_ONLINE,
|
||||
NOTE_INFO,
|
||||
MEDIA_BROWSER,
|
||||
IMPORT,
|
||||
EXPORT
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue