mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-06 17:41:52 -04:00
feat: migrate frontend app to nextjs app router
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
5b5dabc84e
commit
8602645bea
108 changed files with 893 additions and 1188 deletions
|
@ -1,3 +1,4 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
import { DARK_MODE_LOCAL_STORAGE_KEY } from '../../../hooks/dark-mode/use-save-dark-mode-preference-to-local-storage'
|
||||
import { setDarkModePreference } from '../../../redux/dark-mode/methods'
|
||||
import { DarkModePreference } from '../../../redux/dark-mode/types'
|
||||
import { isClientSideRendering } from '../../../utils/is-client-side-rendering'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
|
||||
const logger = new Logger('Dark mode initializer')
|
||||
|
@ -29,9 +28,6 @@ export const loadDarkMode = (): Promise<void> => {
|
|||
* {@link false} if the user doesn't prefer dark mode or if the value couldn't be read from local storage.
|
||||
*/
|
||||
const fetchDarkModeFromLocalStorage = (): DarkModePreference => {
|
||||
if (!isClientSideRendering()) {
|
||||
return DarkModePreference.AUTO
|
||||
}
|
||||
try {
|
||||
const colorScheme = window.localStorage.getItem(DARK_MODE_LOCAL_STORAGE_KEY)
|
||||
if (colorScheme === 'dark') {
|
||||
|
|
|
@ -36,6 +36,9 @@ export const setUpI18n = async (): Promise<void> => {
|
|||
}
|
||||
})
|
||||
|
||||
i18n.on('languageChanged', (language) => (Settings.defaultLocale = language))
|
||||
i18n.on('languageChanged', (language) => {
|
||||
Settings.defaultLocale = language
|
||||
document.documentElement.lang = i18n.language
|
||||
})
|
||||
Settings.defaultLocale = i18n.language
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { createContext, useState } from 'react'
|
||||
import React, { createContext } from 'react'
|
||||
|
||||
export interface BaseUrls {
|
||||
renderer: string
|
||||
|
@ -27,10 +28,9 @@ export const BaseUrlContextProvider: React.FC<PropsWithChildren<BaseUrlContextPr
|
|||
baseUrls,
|
||||
children
|
||||
}) => {
|
||||
const [baseUrlState] = useState<undefined | BaseUrls>(() => baseUrls)
|
||||
return baseUrlState === undefined ? (
|
||||
return baseUrls === undefined ? (
|
||||
<span className={'text-white bg-dark'}>HedgeDoc is not configured correctly! Please check the server log.</span>
|
||||
) : (
|
||||
<baseUrlContext.Provider value={baseUrlState}>{children}</baseUrlContext.Provider>
|
||||
<baseUrlContext.Provider value={baseUrls}>{children}</baseUrlContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
import { UiIcon } from '../../icons/ui-icon'
|
||||
import { ShowIf } from '../../show-if/show-if'
|
||||
|
@ -30,7 +29,7 @@ export const CopyableField: React.FC<CopyableFieldProps> = ({ content, shareOrig
|
|||
useTranslation()
|
||||
|
||||
const sharingSupported = useMemo(
|
||||
() => shareOriginUrl !== undefined && isClientSideRendering() && typeof navigator.share === 'function',
|
||||
() => shareOriginUrl !== undefined && typeof navigator.share === 'function',
|
||||
[shareOriginUrl]
|
||||
)
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
import { ShowIf } from '../../show-if/show-if'
|
||||
import type { ReactElement, RefObject } from 'react'
|
||||
|
@ -45,11 +44,6 @@ export const useCopyOverlay = (
|
|||
}, [reset, showState])
|
||||
|
||||
const copyToClipboard = useCallback(() => {
|
||||
if (!isClientSideRendering()) {
|
||||
setShowState(SHOW_STATE.ERROR)
|
||||
log.error('Clipboard not available in server side rendering')
|
||||
return
|
||||
}
|
||||
if (typeof navigator.clipboard === 'undefined') {
|
||||
setShowState(SHOW_STATE.ERROR)
|
||||
return
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { getConfig } from '../../../api/config'
|
||||
import type { FrontendConfig } from '../../../api/config/types'
|
||||
import { useBaseUrl } from '../../../hooks/common/use-base-url'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import { frontendConfigContext } from './context'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
const logger = new Logger('FrontendConfigContextProvider')
|
||||
import React from 'react'
|
||||
|
||||
interface FrontendConfigContextProviderProps extends PropsWithChildren {
|
||||
config?: FrontendConfig
|
||||
|
@ -24,22 +20,9 @@ interface FrontendConfigContextProviderProps extends PropsWithChildren {
|
|||
* @param children the react elements to show if the config is valid
|
||||
*/
|
||||
export const FrontendConfigContextProvider: React.FC<FrontendConfigContextProviderProps> = ({ config, children }) => {
|
||||
const [configState, setConfigState] = useState<undefined | FrontendConfig>(() => config)
|
||||
|
||||
const baseUrl = useBaseUrl()
|
||||
|
||||
useEffect(() => {
|
||||
if (config === undefined && configState === undefined) {
|
||||
logger.debug('Fetching Config client side')
|
||||
getConfig(baseUrl)
|
||||
.then((config) => setConfigState(config))
|
||||
.catch((error) => logger.error(error))
|
||||
}
|
||||
}, [baseUrl, config, configState])
|
||||
|
||||
return configState === undefined ? (
|
||||
return config === undefined ? (
|
||||
<span className={'text-white bg-dark'}>No frontend config received! Please check the server log.</span>
|
||||
) : (
|
||||
<frontendConfigContext.Provider value={configState}>{children}</frontendConfigContext.Provider>
|
||||
<frontendConfigContext.Provider value={config}>{children}</frontendConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -38,6 +38,8 @@ exports[`create non existing note hint renders an button as initial state 1`] =
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`create non existing note hint renders nothing if no note id has been provided 1`] = `<div />`;
|
||||
|
||||
exports[`create non existing note hint shows an error message if note couldn't be created 1`] = `
|
||||
<div>
|
||||
<div
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
import * as createNoteWithPrimaryAliasModule from '../../../api/notes'
|
||||
import type { Note, NoteMetadata } from '../../../api/notes/types'
|
||||
import * as useSingleStringUrlParameterModule from '../../../hooks/common/use-single-string-url-parameter'
|
||||
import { mockI18n } from '../../../test-utils/mock-i18n'
|
||||
import { CreateNonExistingNoteHint } from './create-non-existing-note-hint'
|
||||
import { waitForOtherPromisesToFinish } from '@hedgedoc/commons'
|
||||
|
@ -18,14 +17,6 @@ jest.mock('../../../hooks/common/use-single-string-url-parameter')
|
|||
describe('create non existing note hint', () => {
|
||||
const mockedNoteId = 'mockedNoteId'
|
||||
|
||||
const mockGetNoteIdQueryParameter = () => {
|
||||
const expectedQueryParameter = 'noteId'
|
||||
jest.spyOn(useSingleStringUrlParameterModule, 'useSingleStringUrlParameter').mockImplementation((parameter) => {
|
||||
expect(parameter).toBe(expectedQueryParameter)
|
||||
return mockedNoteId
|
||||
})
|
||||
}
|
||||
|
||||
const mockCreateNoteWithPrimaryAlias = () => {
|
||||
jest
|
||||
.spyOn(createNoteWithPrimaryAliasModule, 'createNoteWithPrimaryAlias')
|
||||
|
@ -59,14 +50,24 @@ describe('create non existing note hint', () => {
|
|||
jest.resetModules()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetNoteIdQueryParameter()
|
||||
it('renders nothing if no note id has been provided', async () => {
|
||||
const onNoteCreatedCallback = jest.fn()
|
||||
const view = render(
|
||||
<CreateNonExistingNoteHint noteId={undefined} onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
|
||||
)
|
||||
await waitForOtherPromisesToFinish()
|
||||
expect(onNoteCreatedCallback).not.toBeCalled()
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders an button as initial state', async () => {
|
||||
mockCreateNoteWithPrimaryAlias()
|
||||
const onNoteCreatedCallback = jest.fn()
|
||||
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
|
||||
const view = render(
|
||||
<CreateNonExistingNoteHint
|
||||
noteId={mockedNoteId}
|
||||
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
|
||||
)
|
||||
await screen.findByTestId('createNoteMessage')
|
||||
await waitForOtherPromisesToFinish()
|
||||
expect(onNoteCreatedCallback).not.toBeCalled()
|
||||
|
@ -76,7 +77,11 @@ describe('create non existing note hint', () => {
|
|||
it('renders a waiting message when button is clicked', async () => {
|
||||
mockCreateNoteWithPrimaryAlias()
|
||||
const onNoteCreatedCallback = jest.fn()
|
||||
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
|
||||
const view = render(
|
||||
<CreateNonExistingNoteHint
|
||||
noteId={mockedNoteId}
|
||||
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
|
||||
)
|
||||
const button = await screen.findByTestId('createNoteButton')
|
||||
await act<void>(() => {
|
||||
button.click()
|
||||
|
@ -92,7 +97,11 @@ describe('create non existing note hint', () => {
|
|||
it('shows success message when the note has been created', async () => {
|
||||
mockCreateNoteWithPrimaryAlias()
|
||||
const onNoteCreatedCallback = jest.fn()
|
||||
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
|
||||
const view = render(
|
||||
<CreateNonExistingNoteHint
|
||||
noteId={mockedNoteId}
|
||||
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
|
||||
)
|
||||
const button = await screen.findByTestId('createNoteButton')
|
||||
await act<void>(() => {
|
||||
button.click()
|
||||
|
@ -108,7 +117,11 @@ describe('create non existing note hint', () => {
|
|||
it("shows an error message if note couldn't be created", async () => {
|
||||
mockFailingCreateNoteWithPrimaryAlias()
|
||||
const onNoteCreatedCallback = jest.fn()
|
||||
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
|
||||
const view = render(
|
||||
<CreateNonExistingNoteHint
|
||||
noteId={mockedNoteId}
|
||||
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
|
||||
)
|
||||
const button = await screen.findByTestId('createNoteButton')
|
||||
await act<void>(() => {
|
||||
button.click()
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { createNoteWithPrimaryAlias } from '../../../api/notes'
|
||||
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
|
||||
import { testId } from '../../../utils/test-id'
|
||||
import { UiIcon } from '../icons/ui-icon'
|
||||
import { ShowIf } from '../show-if/show-if'
|
||||
|
@ -20,6 +19,7 @@ import { useAsyncFn } from 'react-use'
|
|||
|
||||
export interface CreateNonExistingNoteHintProps {
|
||||
onNoteCreated: () => void
|
||||
noteId: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -27,17 +27,16 @@ export interface CreateNonExistingNoteHintProps {
|
|||
* When the button was clicked it also shows the progress.
|
||||
*
|
||||
* @param onNoteCreated A function that will be called after the note was created.
|
||||
* @param noteId The wanted id for the note to create
|
||||
*/
|
||||
export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps> = ({ onNoteCreated }) => {
|
||||
export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps> = ({ onNoteCreated, noteId }) => {
|
||||
useTranslation()
|
||||
const noteIdFromUrl = useSingleStringUrlParameter('noteId', undefined)
|
||||
|
||||
const [returnState, createNote] = useAsyncFn(async () => {
|
||||
if (noteIdFromUrl === undefined) {
|
||||
throw new Error('Note id not set')
|
||||
if (noteId !== undefined) {
|
||||
return await createNoteWithPrimaryAlias('', noteId)
|
||||
}
|
||||
return await createNoteWithPrimaryAlias('', noteIdFromUrl)
|
||||
}, [noteIdFromUrl])
|
||||
}, [noteId])
|
||||
|
||||
const onClickHandler = useCallback(() => {
|
||||
void createNote()
|
||||
|
@ -49,7 +48,7 @@ export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps>
|
|||
}
|
||||
}, [onNoteCreated, returnState.value])
|
||||
|
||||
if (noteIdFromUrl === undefined) {
|
||||
if (noteId === undefined) {
|
||||
return null
|
||||
} else if (returnState.value) {
|
||||
return (
|
||||
|
@ -76,7 +75,7 @@ export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps>
|
|||
return (
|
||||
<Alert variant={'info'} {...testId('createNoteMessage')} className={'mt-5'}>
|
||||
<span>
|
||||
<Trans i18nKey={'noteLoadingBoundary.createNote.question'} values={{ aliasName: noteIdFromUrl }} />
|
||||
<Trans i18nKey={'noteLoadingBoundary.createNote.question'} values={{ aliasName: noteId }} />
|
||||
</span>
|
||||
<div className={'mt-3'}>
|
||||
<Button
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { getNote } from '../../../../api/notes'
|
||||
import { useSingleStringUrlParameter } from '../../../../hooks/common/use-single-string-url-parameter'
|
||||
import { setNoteDataFromServer } from '../../../../redux/note-details/methods'
|
||||
import { useAsyncFn } from 'react-use'
|
||||
import type { AsyncState } from 'react-use/lib/useAsyncFn'
|
||||
|
@ -14,15 +13,13 @@ import type { AsyncState } from 'react-use/lib/useAsyncFn'
|
|||
*
|
||||
* @return An {@link AsyncState async state} that represents the current state of the loading process.
|
||||
*/
|
||||
export const useLoadNoteFromServer = (): [AsyncState<boolean>, () => void] => {
|
||||
const id = useSingleStringUrlParameter('noteId', undefined)
|
||||
|
||||
export const useLoadNoteFromServer = (noteId: string | undefined): [AsyncState<boolean>, () => void] => {
|
||||
return useAsyncFn(async (): Promise<boolean> => {
|
||||
if (id === undefined) {
|
||||
if (noteId === undefined) {
|
||||
throw new Error('Invalid id')
|
||||
}
|
||||
const noteFromServer = await getNote(id)
|
||||
const noteFromServer = await getNote(noteId)
|
||||
setNoteDataFromServer(noteFromServer)
|
||||
return true
|
||||
}, [id])
|
||||
}, [noteId])
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import { ApiError } from '../../../api/common/api-error'
|
|||
import * as getNoteModule from '../../../api/notes'
|
||||
import type { Note } from '../../../api/notes/types'
|
||||
import * as LoadingScreenModule from '../../../components/application-loader/loading-screen/loading-screen'
|
||||
import * as useSingleStringUrlParameterModule from '../../../hooks/common/use-single-string-url-parameter'
|
||||
import * as setNoteDataFromServerModule from '../../../redux/note-details/methods'
|
||||
import { mockI18n } from '../../../test-utils/mock-i18n'
|
||||
import { testId } from '../../../utils/test-id'
|
||||
|
@ -64,17 +63,8 @@ describe('Note loading boundary', () => {
|
|||
</Fragment>
|
||||
)
|
||||
})
|
||||
mockGetNoteIdQueryParameter()
|
||||
})
|
||||
|
||||
const mockGetNoteIdQueryParameter = () => {
|
||||
const expectedQueryParameter = 'noteId'
|
||||
jest.spyOn(useSingleStringUrlParameterModule, 'useSingleStringUrlParameter').mockImplementation((parameter) => {
|
||||
expect(parameter).toBe(expectedQueryParameter)
|
||||
return mockedNoteId
|
||||
})
|
||||
}
|
||||
|
||||
const mockGetNoteApiCall = (returnValue: Note) => {
|
||||
jest.spyOn(getNoteModule, 'getNote').mockImplementation((id) => {
|
||||
expect(id).toBe(mockedNoteId)
|
||||
|
@ -105,7 +95,7 @@ describe('Note loading boundary', () => {
|
|||
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
|
||||
|
||||
const view = render(
|
||||
<NoteLoadingBoundary>
|
||||
<NoteLoadingBoundary noteId={mockedNoteId}>
|
||||
<span data-testid={'success'}>success!</span>
|
||||
</NoteLoadingBoundary>
|
||||
)
|
||||
|
@ -121,7 +111,7 @@ describe('Note loading boundary', () => {
|
|||
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
|
||||
|
||||
const view = render(
|
||||
<NoteLoadingBoundary>
|
||||
<NoteLoadingBoundary noteId={mockedNoteId}>
|
||||
<span data-testid={'success'}>success!</span>
|
||||
</NoteLoadingBoundary>
|
||||
)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
|
@ -17,15 +18,20 @@ import React, { useEffect, useMemo } from 'react'
|
|||
|
||||
const logger = new Logger('NoteLoadingBoundary')
|
||||
|
||||
export interface NoteIdProps {
|
||||
noteId: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the note identified by the note-id in the URL.
|
||||
* During the loading a {@link LoadingScreen loading screen} will be rendered instead of the child elements.
|
||||
* The boundary also shows errors that occur during the loading process.
|
||||
*
|
||||
* @param children The react elements that will be shown when the loading was successful.
|
||||
* @param children The react elements that will be shown when the loading was successful
|
||||
* @param noteId the id of the note to load
|
||||
*/
|
||||
export const NoteLoadingBoundary: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const [{ error, loading, value }, loadNoteFromServer] = useLoadNoteFromServer()
|
||||
export const NoteLoadingBoundary: React.FC<PropsWithChildren<NoteIdProps>> = ({ children, noteId }) => {
|
||||
const [{ error, loading, value }, loadNoteFromServer] = useLoadNoteFromServer(noteId)
|
||||
|
||||
useEffect(() => {
|
||||
loadNoteFromServer()
|
||||
|
@ -46,11 +52,11 @@ export const NoteLoadingBoundary: React.FC<PropsWithChildren> = ({ children }) =
|
|||
titleI18nKey={`${errorI18nKeyPrefix}.title`}
|
||||
descriptionI18nKey={`${errorI18nKeyPrefix}.description`}>
|
||||
<ShowIf condition={error instanceof ApiError && error.statusCode === 404}>
|
||||
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} />
|
||||
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} noteId={noteId} />
|
||||
</ShowIf>
|
||||
</CommonErrorPage>
|
||||
)
|
||||
}, [error, loadNoteFromServer])
|
||||
}, [error, loadNoteFromServer, noteId])
|
||||
|
||||
return (
|
||||
<CustomAsyncLoadingBoundary
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,10 +3,9 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Logger } from '../../utils/logger'
|
||||
import { testId } from '../../utils/test-id'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
export interface RedirectProps {
|
||||
|
@ -14,8 +13,6 @@ export interface RedirectProps {
|
|||
replace?: boolean
|
||||
}
|
||||
|
||||
const logger = new Logger('Redirect')
|
||||
|
||||
/**
|
||||
* Redirects the user to another URL. Can be external or internal.
|
||||
*
|
||||
|
@ -26,9 +23,7 @@ export const Redirect: React.FC<RedirectProps> = ({ to, replace }) => {
|
|||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
;(replace ? router.replace(to) : router.push(to)).catch((error: Error) => {
|
||||
logger.error(`Error while redirecting to ${to}`, error)
|
||||
})
|
||||
replace ? router.replace(to) : router.push(to)
|
||||
}, [replace, router, to])
|
||||
|
||||
return (
|
||||
|
|
|
@ -3,16 +3,13 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplyDarkModeStyle } from '../../hooks/dark-mode/use-apply-dark-mode-style'
|
||||
import { useSaveDarkModePreferenceToLocalStorage } from '../../hooks/dark-mode/use-save-dark-mode-preference-to-local-storage'
|
||||
import { MotdModal } from '../global-dialogs/motd-modal/motd-modal'
|
||||
import { EditorAppBar } from '../layout/app-bar/editor-app-bar'
|
||||
import { CommunicatorImageLightbox } from '../markdown-renderer/extensions/image/communicator-image-lightbox'
|
||||
import { ExtensionEventEmitterProvider } from '../markdown-renderer/hooks/use-extension-event-emitter'
|
||||
import { ChangeEditorContentContextProvider } from './change-content-context/codemirror-reference-context'
|
||||
import { EditorPane } from './editor-pane/editor-pane'
|
||||
import { useComponentsFromAppExtensions } from './editor-pane/hooks/use-components-from-app-extensions'
|
||||
import { HeadMetaProperties } from './head-meta-properties/head-meta-properties'
|
||||
import { useNoteAndAppTitle } from './head-meta-properties/use-note-and-app-title'
|
||||
import { useScrollState } from './hooks/use-scroll-state'
|
||||
import { useSetScrollSource } from './hooks/use-set-scroll-source'
|
||||
import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry'
|
||||
|
@ -33,8 +30,6 @@ export enum ScrollSource {
|
|||
export const EditorPageContent: React.FC = () => {
|
||||
useTranslation()
|
||||
|
||||
useApplyDarkModeStyle()
|
||||
useSaveDarkModePreferenceToLocalStorage()
|
||||
useUpdateLocalHistoryEntry()
|
||||
|
||||
const scrollSource = useRef<ScrollSource>(ScrollSource.EDITOR)
|
||||
|
@ -67,14 +62,13 @@ export const EditorPageContent: React.FC = () => {
|
|||
)
|
||||
|
||||
const editorExtensionComponents = useComponentsFromAppExtensions()
|
||||
useNoteAndAppTitle()
|
||||
|
||||
return (
|
||||
<ChangeEditorContentContextProvider>
|
||||
<ExtensionEventEmitterProvider>
|
||||
{editorExtensionComponents}
|
||||
<CommunicatorImageLightbox />
|
||||
<HeadMetaProperties />
|
||||
<MotdModal />
|
||||
<div className={'d-flex flex-column vh-100'}>
|
||||
<EditorAppBar />
|
||||
<div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}>
|
||||
|
|
|
@ -4,16 +4,13 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useNoteTitle } from '../../../../../hooks/common/use-note-title'
|
||||
import { Logger } from '../../../../../utils/logger'
|
||||
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { MessageType } from '@hedgedoc/commons'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
const logger = new Logger('UseOnNoteDeleted')
|
||||
|
||||
/**
|
||||
* Hook that redirects the user to the history page and displays a notification when the note is deleted.
|
||||
*
|
||||
|
@ -30,9 +27,7 @@ export const useOnNoteDeleted = (websocketConnection: MessageTransporter): void
|
|||
noteTitle
|
||||
}
|
||||
})
|
||||
router?.push('/history').catch((error: Error) => {
|
||||
logger.error(`Error while redirecting to /history`, error)
|
||||
})
|
||||
router.push('/history')
|
||||
}, [router, noteTitle, dispatchUiNotification])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { store } from '../../../../../redux'
|
||||
import { setRealtimeUsers } from '../../../../../redux/realtime/methods'
|
||||
import { RealtimeStatusActionType } from '../../../../../redux/realtime/types'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { MessageType } from '@hedgedoc/commons'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
|
@ -40,4 +42,13 @@ export const useReceiveRealtimeUsers = (messageTransporter: MessageTransporter):
|
|||
messageTransporter.sendMessage({ type: MessageType.REALTIME_USER_STATE_REQUEST })
|
||||
}
|
||||
}, [isConnected, messageTransporter])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
store.dispatch({
|
||||
type: RealtimeStatusActionType.RESET_REALTIME_STATUS
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { LicenseLinkHead } from './license-link-head'
|
||||
import { NoteAndAppTitleHead } from './note-and-app-title-head'
|
||||
import { OpengraphHead } from './opengraph-head'
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
/**
|
||||
* Renders all HTML head tags that should be present for a note.
|
||||
*/
|
||||
export const HeadMetaProperties: React.FC = () => {
|
||||
return (
|
||||
<Fragment>
|
||||
<NoteAndAppTitleHead />
|
||||
<OpengraphHead />
|
||||
<LicenseLinkHead />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import Head from 'next/head'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Renders the license link tag if a license is set in the frontmatter.
|
||||
*/
|
||||
export const LicenseLinkHead: React.FC = () => {
|
||||
const license = useApplicationState((state) => state.noteDetails.frontmatter.license)
|
||||
|
||||
const optionalLinkElement = useMemo(() => {
|
||||
if (!license || license.trim() === '') {
|
||||
return null
|
||||
}
|
||||
return <link rel={'license'} href={license} />
|
||||
}, [license])
|
||||
|
||||
return <Head>{optionalLinkElement}</Head>
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { useNoteTitle } from '../../../hooks/common/use-note-title'
|
||||
import Head from 'next/head'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Returns the meta tags for the opengraph protocol as defined in the note frontmatter.
|
||||
*/
|
||||
export const OpengraphHead: React.FC = () => {
|
||||
const noteTitle = useNoteTitle()
|
||||
const openGraphData = useApplicationState((state) => state.noteDetails.frontmatter.opengraph)
|
||||
const openGraphMetaElements = useMemo(() => {
|
||||
const elements = Object.entries(openGraphData)
|
||||
.filter(([, value]) => value && String(value).trim() !== '')
|
||||
.map(([key, value]) => <meta property={`og:${key}`} content={value} key={key} />)
|
||||
if (!('title' in openGraphData)) {
|
||||
elements.push(<meta property={'og:title'} content={noteTitle} key={'title'} />)
|
||||
}
|
||||
return elements
|
||||
}, [noteTitle, openGraphData])
|
||||
|
||||
return <Head>{openGraphMetaElements}</Head>
|
||||
}
|
|
@ -6,13 +6,12 @@
|
|||
import { useAppTitle } from '../../../hooks/common/use-app-title'
|
||||
import { useNoteTitle } from '../../../hooks/common/use-note-title'
|
||||
import { useHasMarkdownContentBeenChangedInBackground } from './hooks/use-has-markdown-content-been-changed-in-background'
|
||||
import Head from 'next/head'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Sets the note and app title for the browser window
|
||||
*/
|
||||
export const NoteAndAppTitleHead: React.FC = () => {
|
||||
export const useNoteAndAppTitle = (): void => {
|
||||
const noteTitle = useNoteTitle()
|
||||
const appTitle = useAppTitle()
|
||||
const showDot = useHasMarkdownContentBeenChangedInBackground()
|
||||
|
@ -21,9 +20,7 @@ export const NoteAndAppTitleHead: React.FC = () => {
|
|||
return (showDot ? '• ' : '') + noteTitle + ' - ' + appTitle
|
||||
}, [appTitle, noteTitle, showDot])
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>{noteAndAppTitle}</title>
|
||||
</Head>
|
||||
)
|
||||
useEffect(() => {
|
||||
document.title = noteAndAppTitle
|
||||
}, [noteAndAppTitle])
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { resetRealtimeStatus } from '../../redux/realtime/methods'
|
||||
import { LoadingScreen } from '../application-loader/loading-screen/loading-screen'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { Fragment, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Resets the realtime status in the global application state to its initial state before loading the given child elements.
|
||||
*
|
||||
* @param children The children to load after the reset
|
||||
*/
|
||||
export const ResetRealtimeStateBoundary: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const [globalStateInitialized, setGlobalStateInitialized] = useState(false)
|
||||
useEffect(() => {
|
||||
resetRealtimeStatus()
|
||||
setGlobalStateInitialized(true)
|
||||
}, [])
|
||||
if (!globalStateInitialized) {
|
||||
return <LoadingScreen />
|
||||
} else {
|
||||
return <Fragment>{children}</Fragment>
|
||||
}
|
||||
}
|
|
@ -7,19 +7,16 @@ import { deleteNote } from '../../../../../api/notes'
|
|||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { useBooleanState } from '../../../../../hooks/common/use-boolean-state'
|
||||
import { cypressId } from '../../../../../utils/cypress-attribute'
|
||||
import { Logger } from '../../../../../utils/logger'
|
||||
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
||||
import { SidebarButton } from '../../sidebar-button/sidebar-button'
|
||||
import type { SpecificSidebarEntryProps } from '../../types'
|
||||
import { DeleteNoteModal } from './delete-note-modal'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { Fragment, useCallback } from 'react'
|
||||
import { Trash as IconTrash } from 'react-bootstrap-icons'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = new Logger('note-deletion')
|
||||
|
||||
/**
|
||||
* Sidebar entry that can be used to delete the current note.
|
||||
*
|
||||
|
@ -35,9 +32,7 @@ export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarE
|
|||
|
||||
const deleteNoteAndCloseDialog = useCallback(() => {
|
||||
deleteNote(noteId)
|
||||
.then(() => {
|
||||
router.push('/history').catch((reason) => logger.error('Error while redirecting to /history', reason))
|
||||
})
|
||||
.then(() => router.push('/history'))
|
||||
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
|
||||
.finally(closeModal)
|
||||
}, [closeModal, noteId, router, showErrorNotification])
|
||||
|
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import links from '../../links.json'
|
||||
import { Logger } from '../../utils/logger'
|
||||
import { UiIcon } from '../common/icons/ui-icon'
|
||||
import { ExternalLink } from '../common/links/external-link'
|
||||
import type { ErrorInfo, PropsWithChildren, ReactNode } from 'react'
|
||||
import React, { Component } from 'react'
|
||||
import { Button, Container } from 'react-bootstrap'
|
||||
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons'
|
||||
|
||||
const log = new Logger('ErrorBoundary')
|
||||
|
||||
/**
|
||||
* An error boundary for the whole application.
|
||||
* The text in this is not translated, because the error could be part of the translation framework,
|
||||
* and we still want to display something to the user that's meaningful (and searchable).
|
||||
*/
|
||||
export class ErrorBoundary extends Component<PropsWithChildren<unknown>> {
|
||||
state: {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
constructor(props: Readonly<unknown>) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
// Update state so the next render will show the fallback UI.
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
log.error('Error catched', error, errorInfo)
|
||||
}
|
||||
|
||||
refreshPage(): void {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
render(): ReactNode | undefined {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Container className='text-light d-flex flex-column mvh-100'>
|
||||
<div className='text-light d-flex flex-column align-items-center justify-content-center my-5'>
|
||||
<h1>An unknown error occurred</h1>
|
||||
<p>
|
||||
Don't worry, this happens sometimes. If this is the first time you see this page then try reloading
|
||||
the app.
|
||||
</p>
|
||||
If you can reproduce this error, then we would be glad if you 
|
||||
<ExternalLink text={'open an issue on github'} href={links.issues} className={'text-primary'} />
|
||||
  or <ExternalLink text={'contact us on matrix.'} href={links.chat} className={'text-primary'} />
|
||||
<Button onClick={() => this.refreshPage()} title={'Reload App'} className={'mt-4'}>
|
||||
<UiIcon icon={IconArrowRepeat} />
|
||||
Reload App
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
} else {
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
|
||||
import { useOutlineButtonVariant } from '../../../hooks/dark-mode/use-outline-button-variant'
|
||||
import { useSaveDarkModePreferenceToLocalStorage } from '../../../hooks/dark-mode/use-save-dark-mode-preference-to-local-storage'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { IconButton } from '../../common/icon-button/icon-button'
|
||||
import { SettingsModal } from './settings-modal'
|
||||
|
@ -19,6 +20,7 @@ export type SettingsButtonProps = Omit<ButtonProps, 'onClick'>
|
|||
export const SettingsButton: React.FC<SettingsButtonProps> = (props) => {
|
||||
const [show, showModal, hideModal] = useBooleanState(false)
|
||||
const buttonVariant = useOutlineButtonVariant()
|
||||
useSaveDarkModePreferenceToLocalStorage()
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
|
|
@ -20,13 +20,13 @@ export const historyToolbarStateContext = createContext<HistoryToolbarStateWithD
|
|||
* @param children The children that should receive the toolbar state via context.
|
||||
*/
|
||||
export const HistoryToolbarStateContextProvider: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||
const urlParameterSearch = useSingleStringUrlParameter('search', '')
|
||||
const urlParameterSelectedTags = useArrayStringUrlParameter('selectedTags')
|
||||
const search = useSingleStringUrlParameter('search', '')
|
||||
const selectedTags = useArrayStringUrlParameter('selectedTags')
|
||||
|
||||
const stateWithDispatcher = useState<HistoryToolbarState>(() => ({
|
||||
viewState: ViewStateEnum.CARD,
|
||||
search: urlParameterSearch,
|
||||
selectedTags: urlParameterSelectedTags,
|
||||
search: search,
|
||||
selectedTags: selectedTags,
|
||||
titleSortDirection: SortModeEnum.no,
|
||||
lastVisitedSortDirection: SortModeEnum.down
|
||||
}))
|
||||
|
|
|
@ -3,42 +3,46 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useArrayStringUrlParameter } from '../../../../hooks/common/use-array-string-url-parameter'
|
||||
import { useSingleStringUrlParameter } from '../../../../hooks/common/use-single-string-url-parameter'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
import { useHistoryToolbarState } from './use-history-toolbar-state'
|
||||
import equal from 'fast-deep-equal'
|
||||
import { useRouter } from 'next/router'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const logger = new Logger('useSyncToolbarStateToUrl')
|
||||
|
||||
/**
|
||||
* Pushes the current search and tag selection into the navigation history stack of the browser.
|
||||
*/
|
||||
export const useSyncToolbarStateToUrlEffect = (): void => {
|
||||
const router = useRouter()
|
||||
const urlParameterSearch = useSingleStringUrlParameter('search', '')
|
||||
const urlParameterSelectedTags = useArrayStringUrlParameter('selectedTags')
|
||||
const searchParams = useSearchParams()
|
||||
const [state] = useHistoryToolbarState()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (!equal(state.search, urlParameterSearch) || !equal(state.selectedTags, urlParameterSelectedTags)) {
|
||||
router
|
||||
.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
search: state.search === '' ? [] : state.search,
|
||||
selectedTags: state.selectedTags
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
shallow: true
|
||||
}
|
||||
)
|
||||
.catch(() => logger.error("Can't update route"))
|
||||
if (!searchParams || !pathname) {
|
||||
return
|
||||
}
|
||||
}, [state, router, urlParameterSearch, urlParameterSelectedTags])
|
||||
|
||||
const urlParameterSearch = searchParams.get('search') ?? ''
|
||||
const urlParameterSelectedTags = searchParams.getAll('selectedTags')
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
let shouldUpdate = false
|
||||
|
||||
if (!equal(state.search, urlParameterSearch)) {
|
||||
if (!state.search) {
|
||||
params.delete('search')
|
||||
} else {
|
||||
params.set('search', state.search)
|
||||
}
|
||||
shouldUpdate = true
|
||||
}
|
||||
if (!equal(state.selectedTags, urlParameterSelectedTags)) {
|
||||
params.delete('selectedTags')
|
||||
state.selectedTags.forEach((tag) => params.append('selectedTags', tag))
|
||||
shouldUpdate = true
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}
|
||||
}, [state, router, searchParams, pathname])
|
||||
}
|
||||
|
|
|
@ -3,9 +3,6 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplyDarkModeStyle } from '../../hooks/dark-mode/use-apply-dark-mode-style'
|
||||
import { useSaveDarkModePreferenceToLocalStorage } from '../../hooks/dark-mode/use-save-dark-mode-preference-to-local-storage'
|
||||
import { MotdModal } from '../global-dialogs/motd-modal/motd-modal'
|
||||
import { BaseAppBar } from '../layout/app-bar/base-app-bar'
|
||||
import { HeaderBar } from './navigation/header-bar/header-bar'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
@ -18,13 +15,9 @@ import { Container } from 'react-bootstrap'
|
|||
* @param children The children that should be rendered on the page.
|
||||
*/
|
||||
export const LandingLayout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
useApplyDarkModeStyle()
|
||||
useSaveDarkModePreferenceToLocalStorage()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BaseAppBar />
|
||||
<MotdModal />
|
||||
<Container className='d-flex flex-column'>
|
||||
<HeaderBar />
|
||||
<div className={'d-flex flex-column justify-content-between flex-fill text-center'}>
|
||||
|
|
|
@ -8,7 +8,7 @@ import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute
|
|||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
import styles from './header-nav-link.module.scss'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Nav } from 'react-bootstrap'
|
||||
|
@ -25,17 +25,17 @@ export interface HeaderNavLinkProps extends PropsWithDataCypressId {
|
|||
* @param props Other navigation item props
|
||||
*/
|
||||
export const HeaderNavLink: React.FC<PropsWithChildren<HeaderNavLinkProps>> = ({ to, children, ...props }) => {
|
||||
const { route } = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const className = useMemo(() => {
|
||||
return concatCssClasses(
|
||||
{
|
||||
[styles.active]: route === to
|
||||
[styles.active]: pathname === to
|
||||
},
|
||||
'nav-link',
|
||||
styles.link
|
||||
)
|
||||
}, [route, to])
|
||||
}, [pathname, to])
|
||||
|
||||
return (
|
||||
<Nav.Item>
|
||||
|
|
|
@ -8,7 +8,7 @@ import { clearUser } from '../../../redux/user/methods'
|
|||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { BoxArrowRight as IconBoxArrowRight } from 'react-bootstrap-icons'
|
||||
|
|
|
@ -3,24 +3,23 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useFrontendConfig } from '../../../../../common/frontend-config-context/use-frontend-config'
|
||||
import { ShowIf } from '../../../../../common/show-if/show-if'
|
||||
import { DropdownHeader } from '../dropdown-header'
|
||||
import { TranslatedDropdownItem } from '../translated-dropdown-item'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import React, { Fragment } from 'react'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFrontendConfig } from '../../../../../common/frontend-config-context/use-frontend-config'
|
||||
|
||||
/**
|
||||
* Renders the legal submenu for the help dropdown.
|
||||
*/
|
||||
export const LegalSubmenu: React.FC = () => {
|
||||
export const LegalSubmenu: React.FC = (): null | ReactElement => {
|
||||
useTranslation()
|
||||
const specialUrls = useFrontendConfig().specialUrls
|
||||
const linksConfigured = useMemo(
|
||||
() => specialUrls.privacy || specialUrls.termsOfUse || specialUrls.imprint,
|
||||
[specialUrls]
|
||||
)
|
||||
|
||||
const linksConfigured = specialUrls?.privacy || specialUrls?.termsOfUse || specialUrls?.imprint
|
||||
|
||||
if (!linksConfigured) {
|
||||
return null
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useAppTitle } from '../../hooks/common/use-app-title'
|
||||
import { FavIcon } from './fav-icon'
|
||||
import Head from 'next/head'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Sets basic browser meta tags.
|
||||
*/
|
||||
export const BaseHead: React.FC = () => {
|
||||
const appTitle = useAppTitle()
|
||||
return (
|
||||
<Head>
|
||||
<title>{appTitle}</title>
|
||||
<FavIcon />
|
||||
<meta content='width=device-width, initial-scale=1' name='viewport' />
|
||||
</Head>
|
||||
)
|
||||
}
|
14
frontend/src/components/layout/dark-mode/dark-mode.tsx
Normal file
14
frontend/src/components/layout/dark-mode/dark-mode.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplyDarkModeStyle } from './use-apply-dark-mode-style'
|
||||
import type React from 'react'
|
||||
|
||||
export const DarkMode: React.FC = () => {
|
||||
useApplyDarkModeStyle()
|
||||
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useDarkModeState } from '../../../hooks/dark-mode/use-dark-mode-state'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Applies the dark mode by adding a css class to the body tag.
|
||||
*/
|
||||
export const useApplyDarkModeStyle = (): void => {
|
||||
const darkMode = useDarkModeState()
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
window.document.body.dataset.bsTheme = 'dark'
|
||||
} else {
|
||||
window.document.body.dataset.bsTheme = 'light'
|
||||
}
|
||||
}, [darkMode])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.document.body.dataset.bsTheme = 'light'
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
37
frontend/src/components/layout/expected-origin-boundary.tsx
Normal file
37
frontend/src/components/layout/expected-origin-boundary.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { headers } from 'next/headers'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
export interface ExpectedOriginBoundaryProps extends PropsWithChildren {
|
||||
expectedOrigin: string
|
||||
}
|
||||
|
||||
export const buildOriginFromHeaders = (): string | undefined => {
|
||||
const headers1 = headers()
|
||||
const host = headers1.get('x-forwarded-host') ?? headers1.get('host')
|
||||
if (host === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const protocol = headers1.get('x-forwarded-proto')?.split(',')[0] ?? 'http'
|
||||
return `${protocol}://${host}`
|
||||
}
|
||||
|
||||
export const ExpectedOriginBoundary: React.FC<ExpectedOriginBoundaryProps> = ({ children, expectedOrigin }) => {
|
||||
const currentOrigin = buildOriginFromHeaders()
|
||||
|
||||
if (new URL(expectedOrigin).origin !== currentOrigin) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
'text-white bg-dark'
|
||||
}>{`You can't open this page using this URL. For this endpoint "${expectedOrigin}" is expected.`}</span>
|
||||
)
|
||||
}
|
||||
return children
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
/**
|
||||
* Sets meta tags for the favicon.
|
||||
*/
|
||||
export const FavIcon: React.FC = () => {
|
||||
return (
|
||||
<Fragment>
|
||||
<link href='/icons/apple-touch-icon.png' rel='apple-touch-icon' sizes='180x180' />
|
||||
<link href='/icons/favicon-32x32.png' rel='icon' sizes='32x32' type='image/png' />
|
||||
<link href='/icons/favicon-16x16.png' rel='icon' sizes='16x16' type='image/png' />
|
||||
<link href='/icons/site.webmanifest' rel='manifest' />
|
||||
<link href='/icons/favicon.ico' rel='shortcut icon' />
|
||||
<link color='#b51f08' href='/icons/safari-pinned-tab.svg' rel='mask-icon' />
|
||||
<meta name='apple-mobile-web-app-title' content='HedgeDoc' />
|
||||
<meta name='application-name' content='HedgeDoc' />
|
||||
<meta name='msapplication-TileColor' content='#b51f08' />
|
||||
<meta name='theme-color' content='#b51f08' />
|
||||
<meta content='/icons/browserconfig.xml' name='msapplication-config' />
|
||||
<meta content='HedgeDoc - Collaborative markdown notes' name='description' />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -28,7 +28,7 @@ export interface OneClickMetadata {
|
|||
}
|
||||
|
||||
const getBackendAuthUrl = (providerIdentifer: string): string => {
|
||||
return `auth/${providerIdentifer}`
|
||||
return `/auth/${providerIdentifer}`
|
||||
}
|
||||
|
||||
const logger = new Logger('GetOneClickProviderMetadata')
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplyDarkModeStyle } from '../../../../hooks/dark-mode/use-apply-dark-mode-style'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
import type { ScrollProps } from '../../../editor-page/synced-scroll/scroll-props'
|
||||
import { useApplyDarkModeStyle } from '../../../layout/dark-mode/use-apply-dark-mode-style'
|
||||
import type { LineMarkers } from '../../../markdown-renderer/extensions/linemarker/add-line-marker-markdown-it-plugin'
|
||||
import { LinemarkerMarkdownExtension } from '../../../markdown-renderer/extensions/linemarker/linemarker-markdown-extension'
|
||||
import { useCalculateLineMarkerPosition } from '../../../markdown-renderer/hooks/use-calculate-line-marker-positions'
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplyDarkModeStyle } from '../../../../hooks/dark-mode/use-apply-dark-mode-style'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
import { useApplyDarkModeStyle } from '../../../layout/dark-mode/use-apply-dark-mode-style'
|
||||
import { useMarkdownExtensions } from '../../../markdown-renderer/hooks/use-markdown-extensions'
|
||||
import { MarkdownToReact } from '../../../markdown-renderer/markdown-to-react/markdown-to-react'
|
||||
import { useOnHeightChange } from '../../hooks/use-on-height-change'
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue