feat: migrate frontend app to nextjs app router
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
|
@ -19,7 +19,7 @@ else
|
|||
fi
|
||||
|
||||
echo "🦔 > Building"
|
||||
next build
|
||||
BUILD_TIME=true next build
|
||||
|
||||
echo "🦔 > Bundling"
|
||||
mv .next/standalone dist
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
describe('Opengraph metadata', () => {
|
||||
beforeEach(() => {
|
||||
cy.visitTestNote()
|
||||
})
|
||||
|
||||
it('includes the note title if not overridden', () => {
|
||||
cy.setCodemirrorContent('---\ntitle: Test title\n---')
|
||||
cy.get('meta[property="og:title"]').should('have.attr', 'content', 'Test title')
|
||||
})
|
||||
|
||||
it('includes the note title if overridden', () => {
|
||||
cy.setCodemirrorContent('---\ntitle: Test title\nopengraph:\n title: Overridden title\n---')
|
||||
cy.get('meta[property="og:title"]').should('have.attr', 'content', 'Overridden title')
|
||||
})
|
||||
|
||||
it('includes custom opengraph tags', () => {
|
||||
cy.setCodemirrorContent('---\nopengraph:\n image: https://dummyimage.com/48\n---')
|
||||
cy.get('meta[property="og:image"]').should('have.attr', 'content', 'https://dummyimage.com/48')
|
||||
})
|
||||
})
|
||||
|
||||
describe('License frontmatter', () => {
|
||||
beforeEach(() => {
|
||||
cy.visitTestNote()
|
||||
})
|
||||
|
||||
it('sets the link tag if defined and not blank', () => {
|
||||
cy.setCodemirrorContent('---\nlicense: https://example.com\n---')
|
||||
cy.get('link[rel="license"]').should('have.attr', 'href', 'https://example.com')
|
||||
})
|
||||
|
||||
it('does not set the link tag if not defined', () => {
|
||||
cy.setCodemirrorContent('---\ntitle: No license for this note\n---')
|
||||
cy.get('link[rel="license"]').should('not.exist')
|
||||
})
|
||||
|
||||
it('does not set the link tag if defined but blank', () => {
|
||||
cy.setCodemirrorContent('---\nlicense: \n---')
|
||||
cy.get('link[rel="license"]').should('not.exist')
|
||||
})
|
||||
})
|
|
@ -4,6 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { AuthProviderType } from '../../src/api/config/types'
|
||||
import { HttpMethod } from '../../src/handler-utils/respond-to-matching-request'
|
||||
|
||||
declare namespace Cypress {
|
||||
interface Chainable {
|
||||
|
@ -80,13 +81,7 @@ export const config = {
|
|||
}
|
||||
|
||||
Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => {
|
||||
return cy.intercept('/api/private/config', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
...config,
|
||||
...additionalConfig
|
||||
}
|
||||
})
|
||||
return cy.request(HttpMethod.POST, '/api/private/config', { ...config, ...additionalConfig })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
{
|
||||
"app": {
|
||||
"slogan": "Ideas grow better together",
|
||||
"title": "Collaborative markdown notes",
|
||||
"icon": "HedgeDoc logo with text"
|
||||
},
|
||||
"notificationTest": {
|
||||
|
|
1
frontend/next-env.d.ts
vendored
|
@ -1,5 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
const { isMockMode, isTestMode, isProfilingMode } = require('./src/utils/test-modes')
|
||||
const { isMockMode, isTestMode, isProfilingMode, isBuildTime } = require('./src/utils/test-modes')
|
||||
const path = require('path')
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin')
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
|
@ -12,15 +12,13 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
|||
|
||||
console.log('Node environment is', process.env.NODE_ENV)
|
||||
|
||||
if (isMockMode) {
|
||||
console.log('Use mock API')
|
||||
}
|
||||
|
||||
if (isTestMode) {
|
||||
console.warn(`This build runs in test mode. This means:
|
||||
- no sandboxed iframe
|
||||
- No sandboxed iframe
|
||||
- Additional data-attributes for e2e tests added to DOM
|
||||
- Editor and renderer are running on the same origin`)
|
||||
- Editor and renderer are running on the same origin
|
||||
- No frontend config caching
|
||||
`)
|
||||
}
|
||||
|
||||
if (isMockMode) {
|
||||
|
@ -28,7 +26,14 @@ if (isMockMode) {
|
|||
- No real data. All API responses are mocked
|
||||
- No persistent data
|
||||
- No realtime editing
|
||||
`)
|
||||
`)
|
||||
}
|
||||
|
||||
if (isBuildTime) {
|
||||
console.warn(`This process runs in build mode. During build time this means:
|
||||
- Editor and Renderer base urls are https://example.org
|
||||
- No frontend config will be fetched
|
||||
`)
|
||||
}
|
||||
|
||||
if (isProfilingMode) {
|
||||
|
@ -54,7 +59,6 @@ const svgrConfig = {
|
|||
/** @type {import('next').NextConfig} */
|
||||
const rawNextConfig = {
|
||||
webpack: (config) => {
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/i,
|
||||
issuer: /\.[jt]sx?$/,
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production ./build.sh",
|
||||
"build:mock": "cross-env NEXT_PUBLIC_USE_MOCK_API=true ./build.sh --keep-mock-api",
|
||||
"build:test": "cross-env NODE_ENV=test NEXT_PUBLIC_TEST_MODE=true ./build.sh --keep-mock-api",
|
||||
"build:mock": "cross-env BUILD_TIME=true NEXT_PUBLIC_USE_MOCK_API=true ./build.sh --keep-mock-api",
|
||||
"build:test": "cross-env BUILD_TIME=true NODE_ENV=test NEXT_PUBLIC_TEST_MODE=true ./build.sh --keep-mock-api",
|
||||
"analyze": "cross-env ANALYZE=true yarn build --profile",
|
||||
"format": "prettier -c \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"",
|
||||
"format:fix": "prettier -w \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"",
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/icons/mstile-150x150.png"/>
|
||||
<TileColor>#b51f08</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
Before Width: | Height: | Size: 428 B |
Before Width: | Height: | Size: 605 B |
Before Width: | Height: | Size: 2 KiB |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.8 KiB |
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||
import type { FrontendConfig } from './types'
|
||||
import { isBuildTime } from '../../utils/test-modes'
|
||||
|
||||
/**
|
||||
* Fetches the frontend config from the backend.
|
||||
|
@ -12,7 +13,10 @@ import type { FrontendConfig } from './types'
|
|||
* @return The frontend config.
|
||||
* @throws {Error} when the api request wasn't successful.
|
||||
*/
|
||||
export const getConfig = async (baseUrl?: string): Promise<FrontendConfig> => {
|
||||
export const getConfig = async (baseUrl?: string): Promise<FrontendConfig | undefined> => {
|
||||
if (isBuildTime) {
|
||||
return undefined
|
||||
}
|
||||
const response = await new GetApiRequestBuilder<FrontendConfig>('config', baseUrl).sendRequest()
|
||||
return response.asParsedJsonObject()
|
||||
}
|
||||
|
|
|
@ -16,8 +16,8 @@ import type { Note, NoteDeletionOptions, NoteMetadata } from './types'
|
|||
* @return Content and metadata of the specified note.
|
||||
* @throws {Error} when the api request wasn't successful.
|
||||
*/
|
||||
export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
|
||||
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias).sendRequest()
|
||||
export const getNote = async (noteIdOrAlias: string, baseUrl?: string): Promise<Note> => {
|
||||
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias, baseUrl).sendRequest()
|
||||
return response.asParsedJsonObject()
|
||||
}
|
||||
|
||||
|
|
37
frontend/src/app/(editor)/[id]/page.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { getNote } from '../../../api/notes'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { baseUrlFromEnvExtractor } from '../../../utils/base-url-from-env-extractor'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
interface PageProps {
|
||||
params: { id: string | undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the user to the editor if the link is a root level direct link to a version 1 note.
|
||||
*/
|
||||
const DirectLinkFallback = async ({ params }: PageProps) => {
|
||||
const baseUrl = baseUrlFromEnvExtractor.extractBaseUrls().editor
|
||||
|
||||
if (params.id === undefined) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
try {
|
||||
const noteData = await getNote(params.id, baseUrl)
|
||||
if (noteData.metadata.version !== 1) {
|
||||
notFound()
|
||||
}
|
||||
} catch (error) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
redirect(`/n/${params.id}`)
|
||||
}
|
||||
|
||||
export default DirectLinkFallback
|
|
@ -1,19 +1,18 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { CheatsheetContent } from '../components/cheatsheet/cheatsheet-content'
|
||||
import { useApplyDarkModeStyle } from '../hooks/dark-mode/use-apply-dark-mode-style'
|
||||
import { CheatsheetContent } from '../../../components/cheatsheet/cheatsheet-content'
|
||||
import type { NextPage } from 'next'
|
||||
import { Container } from 'react-bootstrap'
|
||||
|
||||
const CheatsheetPage: NextPage = () => {
|
||||
useApplyDarkModeStyle()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<CheatsheetContent></CheatsheetContent>
|
||||
<CheatsheetContent />
|
||||
</Container>
|
||||
)
|
||||
}
|
42
frontend/src/app/(editor)/global-error.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { UiIcon } from '../../components/common/icons/ui-icon'
|
||||
import { ExternalLink } from '../../components/common/links/external-link'
|
||||
import links from '../../links.json'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Button, Container } from 'react-bootstrap'
|
||||
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons'
|
||||
|
||||
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<Container className='d-flex flex-column mvh-100'>
|
||||
<div className='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={reset} title={'Reload App'} className={'mt-4'}>
|
||||
<UiIcon icon={IconArrowRepeat} />
|
||||
Reload App
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
|
@ -1,13 +1,15 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { HistoryContent } from '../components/history-page/history-content/history-content'
|
||||
import { HistoryToolbar } from '../components/history-page/history-toolbar/history-toolbar'
|
||||
import { useSafeRefreshHistoryStateCallback } from '../components/history-page/history-toolbar/hooks/use-safe-refresh-history-state'
|
||||
import { HistoryToolbarStateContextProvider } from '../components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider'
|
||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
||||
import { HistoryContent } from '../../../components/history-page/history-content/history-content'
|
||||
import { HistoryToolbar } from '../../../components/history-page/history-toolbar/history-toolbar'
|
||||
import { useSafeRefreshHistoryStateCallback } from '../../../components/history-page/history-toolbar/hooks/use-safe-refresh-history-state'
|
||||
import { HistoryToolbarStateContextProvider } from '../../../components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider'
|
||||
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||
import type { NextPage } from 'next'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Row } from 'react-bootstrap'
|
|
@ -1,15 +1,17 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { CustomBranding } from '../components/common/custom-branding/custom-branding'
|
||||
import { HedgeDocLogoVertical } from '../components/common/hedge-doc-logo/hedge-doc-logo-vertical'
|
||||
import { LogoSize } from '../components/common/hedge-doc-logo/logo-size'
|
||||
import { EditorToRendererCommunicatorContextProvider } from '../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||
import { CoverButtons } from '../components/intro-page/cover-buttons/cover-buttons'
|
||||
import { IntroCustomContent } from '../components/intro-page/intro-custom-content'
|
||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
||||
import { CustomBranding } from '../../../components/common/custom-branding/custom-branding'
|
||||
import { HedgeDocLogoVertical } from '../../../components/common/hedge-doc-logo/hedge-doc-logo-vertical'
|
||||
import { LogoSize } from '../../../components/common/hedge-doc-logo/logo-size'
|
||||
import { EditorToRendererCommunicatorContextProvider } from '../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||
import { CoverButtons } from '../../../components/intro-page/cover-buttons/cover-buttons'
|
||||
import { IntroCustomContent } from '../../../components/intro-page/intro-custom-content'
|
||||
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||
import type { NextPage } from 'next'
|
||||
import React from 'react'
|
||||
import { Trans } from 'react-i18next'
|
61
frontend/src/app/(editor)/layout.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import '../../../global-styles/index.scss'
|
||||
import { ApplicationLoader } from '../../components/application-loader/application-loader'
|
||||
import { BaseUrlContextProvider } from '../../components/common/base-url/base-url-context-provider'
|
||||
import { FrontendConfigContextProvider } from '../../components/common/frontend-config-context/frontend-config-context-provider'
|
||||
import { MotdModal } from '../../components/global-dialogs/motd-modal/motd-modal'
|
||||
import { DarkMode } from '../../components/layout/dark-mode/dark-mode'
|
||||
import { ExpectedOriginBoundary } from '../../components/layout/expected-origin-boundary'
|
||||
import { UiNotificationBoundary } from '../../components/notifications/ui-notification-boundary'
|
||||
import { StoreProvider } from '../../redux/store-provider'
|
||||
import { baseUrlFromEnvExtractor } from '../../utils/base-url-from-env-extractor'
|
||||
import { configureLuxon } from '../../utils/configure-luxon'
|
||||
import type { Metadata } from 'next'
|
||||
import React from 'react'
|
||||
import { getConfig } from '../../api/config'
|
||||
|
||||
configureLuxon()
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls()
|
||||
const frontendConfig = await getConfig(baseUrls.editor)
|
||||
|
||||
return (
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<link color='#b51f08' href='/icons/safari-pinned-tab.svg' rel='mask-icon' />
|
||||
</head>
|
||||
<body>
|
||||
<ExpectedOriginBoundary expectedOrigin={baseUrls.editor}>
|
||||
<BaseUrlContextProvider baseUrls={baseUrls}>
|
||||
<FrontendConfigContextProvider config={frontendConfig}>
|
||||
<StoreProvider>
|
||||
<ApplicationLoader>
|
||||
<DarkMode />
|
||||
<MotdModal />
|
||||
<UiNotificationBoundary>{children}</UiNotificationBoundary>
|
||||
</ApplicationLoader>
|
||||
</StoreProvider>
|
||||
</FrontendConfigContextProvider>
|
||||
</BaseUrlContextProvider>
|
||||
</ExpectedOriginBoundary>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
themeColor: '#b51f08',
|
||||
applicationName: 'HedgeDoc',
|
||||
appleWebApp: {
|
||||
title: 'HedgeDoc'
|
||||
},
|
||||
description: 'HedgeDoc - Ideas grow better together',
|
||||
viewport: 'width=device-width, initial-scale=1',
|
||||
title: 'HedgeDoc',
|
||||
manifest: '/icons/site.webmanifest'
|
||||
}
|
|
@ -1,19 +1,22 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { AuthProviderWithCustomName } from '../api/config/types'
|
||||
import { AuthProviderType } from '../api/config/types'
|
||||
import { useFrontendConfig } from '../components/common/frontend-config-context/use-frontend-config'
|
||||
import { RedirectBack } from '../components/common/redirect-back'
|
||||
import { ShowIf } from '../components/common/show-if/show-if'
|
||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
||||
import { filterOneClickProviders } from '../components/login-page/auth/utils'
|
||||
import { ViaLdap } from '../components/login-page/auth/via-ldap'
|
||||
import { ViaLocal } from '../components/login-page/auth/via-local'
|
||||
import { ViaOneClick } from '../components/login-page/auth/via-one-click'
|
||||
import { useApplicationState } from '../hooks/common/use-application-state'
|
||||
import type { AuthProviderWithCustomName } from '../../../api/config/types'
|
||||
import { AuthProviderType } from '../../../api/config/types'
|
||||
import { useFrontendConfig } from '../../../components/common/frontend-config-context/use-frontend-config'
|
||||
import { RedirectBack } from '../../../components/common/redirect-back'
|
||||
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||
import { filterOneClickProviders } from '../../../components/login-page/auth/utils'
|
||||
import { ViaLdap } from '../../../components/login-page/auth/via-ldap'
|
||||
import { ViaLocal } from '../../../components/login-page/auth/via-local'
|
||||
import { ViaOneClick } from '../../../components/login-page/auth/via-one-click'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import type { NextPage } from 'next'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Card, Col, Row } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
@ -22,7 +25,7 @@ import { Trans, useTranslation } from 'react-i18next'
|
|||
* Renders the login page with buttons and fields for the enabled auth providers.
|
||||
* Redirects the user to the history page if they are already logged in.
|
||||
*/
|
||||
export const LoginPage: React.FC = () => {
|
||||
const LoginPage: NextPage = () => {
|
||||
useTranslation()
|
||||
const authProviders = useFrontendConfig().authProviders
|
||||
const userLoggedIn = useApplicationState((state) => !!state.user)
|
54
frontend/src/app/(editor)/n/[noteId]/page.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { NoteIdProps } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
|
||||
import { NoteLoadingBoundary } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
|
||||
import { EditorPageContent } from '../../../../components/editor-page/editor-page-content'
|
||||
import { EditorToRendererCommunicatorContextProvider } from '../../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||
import type { NextPage } from 'next'
|
||||
import React from 'react'
|
||||
|
||||
interface PageParams {
|
||||
params: NoteIdProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a page that is used by the user to edit markdown notes. It contains the editor and a renderer.
|
||||
*/
|
||||
const EditorPage: NextPage<PageParams> = ({ params }) => {
|
||||
return (
|
||||
<NoteLoadingBoundary noteId={params.noteId}>
|
||||
<EditorToRendererCommunicatorContextProvider>
|
||||
<EditorPageContent />
|
||||
</EditorToRendererCommunicatorContextProvider>
|
||||
</NoteLoadingBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: implement these in generateMetadata. We need these only in SSR.
|
||||
|
||||
See https://github.com/hedgedoc/hedgedoc/issues/4766
|
||||
|
||||
But its problematic because we dont get the opengraph meta data via API.
|
||||
|
||||
<NoteAndAppTitleHead />
|
||||
<OpengraphHead />
|
||||
<LicenseLinkHead />
|
||||
|
||||
export async function generateMetadata({ params }: PageParams): Promise<Metadata> {
|
||||
if (!params.noteId) {
|
||||
return {}
|
||||
}
|
||||
const note = await getNote(params.noteId, getBaseUrls().editor)
|
||||
return {
|
||||
title: `HedgeDoc - ${ note.metadata.title }`
|
||||
description: note.metadata.description
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
export default EditorPage
|
|
@ -1,16 +1,18 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { createNote } from '../api/notes'
|
||||
import type { Note } from '../api/notes/types'
|
||||
import { LoadingScreen } from '../components/application-loader/loading-screen/loading-screen'
|
||||
import { CustomAsyncLoadingBoundary } from '../components/common/async-loading-boundary/custom-async-loading-boundary'
|
||||
import { Redirect } from '../components/common/redirect'
|
||||
import { ShowIf } from '../components/common/show-if/show-if'
|
||||
import { CommonErrorPage } from '../components/error-pages/common-error-page'
|
||||
import { useSingleStringUrlParameter } from '../hooks/common/use-single-string-url-parameter'
|
||||
import { createNote } from '../../../api/notes'
|
||||
import type { Note } from '../../../api/notes/types'
|
||||
import { LoadingScreen } from '../../../components/application-loader/loading-screen/loading-screen'
|
||||
import { CustomAsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/custom-async-loading-boundary'
|
||||
import { Redirect } from '../../../components/common/redirect'
|
||||
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||
import { CommonErrorPage } from '../../../components/error-pages/common-error-page'
|
||||
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
|
||||
import type { NextPage } from 'next'
|
||||
import React from 'react'
|
||||
import { useAsync } from 'react-use'
|
||||
|
@ -18,7 +20,7 @@ import { useAsync } from 'react-use'
|
|||
/**
|
||||
* Creates a new note, optionally including the passed content and redirects to that note.
|
||||
*/
|
||||
export const NewNotePage: NextPage = () => {
|
||||
const NewNotePage: NextPage = () => {
|
||||
const newContent = useSingleStringUrlParameter('content', '')
|
||||
|
||||
const { loading, error, value } = useAsync(() => {
|
|
@ -3,14 +3,14 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { CommonErrorPage } from '../components/error-pages/common-error-page'
|
||||
import { CommonErrorPage } from '../../components/error-pages/common-error-page'
|
||||
import type { NextPage } from 'next'
|
||||
|
||||
/**
|
||||
* Renders a hedgedoc themed 404 page.
|
||||
*/
|
||||
const Custom404: NextPage = () => {
|
||||
const NotFound: NextPage = () => {
|
||||
return <CommonErrorPage titleI18nKey={'errors.notFound.title'} descriptionI18nKey={'errors.notFound.description'} />
|
||||
}
|
||||
|
||||
export default Custom404
|
||||
export default NotFound
|
35
frontend/src/app/(editor)/p/[noteId]/page.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { NoteIdProps } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
|
||||
import { NoteLoadingBoundary } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
|
||||
import { useNoteAndAppTitle } from '../../../../components/editor-page/head-meta-properties/use-note-and-app-title'
|
||||
import { EditorToRendererCommunicatorContextProvider } from '../../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||
import { SlideShowPageContent } from '../../../../components/slide-show-page/slide-show-page-content'
|
||||
import type { NextPage } from 'next'
|
||||
import React from 'react'
|
||||
|
||||
interface PageParams {
|
||||
params: NoteIdProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a page that is used by the user to hold a presentation. It contains the renderer for the presentation.
|
||||
*/
|
||||
const SlideShowPage: NextPage<PageParams> = ({ params }) => {
|
||||
useNoteAndAppTitle()
|
||||
|
||||
return (
|
||||
<NoteLoadingBoundary noteId={params.noteId}>
|
||||
<EditorToRendererCommunicatorContextProvider>
|
||||
<SlideShowPageContent />
|
||||
</EditorToRendererCommunicatorContextProvider>
|
||||
</NoteLoadingBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlideShowPage
|
|
@ -1,17 +1,20 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { AuthProviderType } from '../api/config/types'
|
||||
import { Redirect } from '../components/common/redirect'
|
||||
import { ShowIf } from '../components/common/show-if/show-if'
|
||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
||||
import { ProfileAccessTokens } from '../components/profile-page/access-tokens/profile-access-tokens'
|
||||
import { ProfileAccountManagement } from '../components/profile-page/account-management/profile-account-management'
|
||||
import { ProfileChangePassword } from '../components/profile-page/settings/profile-change-password'
|
||||
import { ProfileDisplayName } from '../components/profile-page/settings/profile-display-name'
|
||||
import { useApplicationState } from '../hooks/common/use-application-state'
|
||||
import { AuthProviderType } from '../../../api/config/types'
|
||||
import { Redirect } from '../../../components/common/redirect'
|
||||
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||
import { ProfileAccessTokens } from '../../../components/profile-page/access-tokens/profile-access-tokens'
|
||||
import { ProfileAccountManagement } from '../../../components/profile-page/account-management/profile-account-management'
|
||||
import { ProfileChangePassword } from '../../../components/profile-page/settings/profile-change-password'
|
||||
import { ProfileDisplayName } from '../../../components/profile-page/settings/profile-display-name'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import type { NextPage } from 'next'
|
||||
import React from 'react'
|
||||
import { Col, Row } from 'react-bootstrap'
|
||||
|
||||
|
@ -19,7 +22,7 @@ import { Col, Row } from 'react-bootstrap'
|
|||
* Profile page that includes forms for changing display name, password (if internal login is used),
|
||||
* managing access tokens and deleting the account.
|
||||
*/
|
||||
export const ProfilePage: React.FC = () => {
|
||||
const ProfilePage: NextPage = () => {
|
||||
const userProvider = useApplicationState((state) => state.user?.authProvider)
|
||||
|
||||
if (!userProvider) {
|
|
@ -1,26 +1,27 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { doLocalRegister } from '../api/auth/local'
|
||||
import type { ApiError } from '../api/common/api-error'
|
||||
import { DisplayNameField } from '../components/common/fields/display-name-field'
|
||||
import { NewPasswordField } from '../components/common/fields/new-password-field'
|
||||
import { PasswordAgainField } from '../components/common/fields/password-again-field'
|
||||
import { UsernameLabelField } from '../components/common/fields/username-label-field'
|
||||
import { useFrontendConfig } from '../components/common/frontend-config-context/use-frontend-config'
|
||||
import { Redirect } from '../components/common/redirect'
|
||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
||||
import { fetchAndSetUser } from '../components/login-page/auth/utils'
|
||||
import { useUiNotifications } from '../components/notifications/ui-notification-boundary'
|
||||
import { RegisterError } from '../components/register-page/register-error'
|
||||
import { RegisterInfos } from '../components/register-page/register-infos'
|
||||
import { useApplicationState } from '../hooks/common/use-application-state'
|
||||
import { useLowercaseOnInputChange } from '../hooks/common/use-lowercase-on-input-change'
|
||||
import { useOnInputChange } from '../hooks/common/use-on-input-change'
|
||||
import { doLocalRegister } from '../../../api/auth/local'
|
||||
import type { ApiError } from '../../../api/common/api-error'
|
||||
import { DisplayNameField } from '../../../components/common/fields/display-name-field'
|
||||
import { NewPasswordField } from '../../../components/common/fields/new-password-field'
|
||||
import { PasswordAgainField } from '../../../components/common/fields/password-again-field'
|
||||
import { UsernameLabelField } from '../../../components/common/fields/username-label-field'
|
||||
import { useFrontendConfig } from '../../../components/common/frontend-config-context/use-frontend-config'
|
||||
import { Redirect } from '../../../components/common/redirect'
|
||||
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||
import { fetchAndSetUser } from '../../../components/login-page/auth/utils'
|
||||
import { useUiNotifications } from '../../../components/notifications/ui-notification-boundary'
|
||||
import { RegisterError } from '../../../components/register-page/register-error'
|
||||
import { RegisterInfos } from '../../../components/register-page/register-infos'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { useLowercaseOnInputChange } from '../../../hooks/common/use-lowercase-on-input-change'
|
||||
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
||||
import type { NextPage } from 'next'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { FormEvent } from 'react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Button, Card, Col, Form, Row } from 'react-bootstrap'
|
||||
|
@ -29,7 +30,7 @@ import { Trans, useTranslation } from 'react-i18next'
|
|||
/**
|
||||
* Renders the registration page with fields for username, display name, password, password retype and information about terms and conditions.
|
||||
*/
|
||||
export const RegisterPage: NextPage = () => {
|
||||
const RegisterPage: NextPage = () => {
|
||||
useTranslation()
|
||||
const router = useRouter()
|
||||
const allowRegister = useFrontendConfig().allowRegister
|
38
frontend/src/app/(editor)/s/[noteId]/page.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { NoteIdProps } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
|
||||
import { NoteLoadingBoundary } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
|
||||
import { DocumentReadOnlyPageContent } from '../../../../components/document-read-only-page/document-read-only-page-content'
|
||||
import { useNoteAndAppTitle } from '../../../../components/editor-page/head-meta-properties/use-note-and-app-title'
|
||||
import { EditorToRendererCommunicatorContextProvider } from '../../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||
import { BaseAppBar } from '../../../../components/layout/app-bar/base-app-bar'
|
||||
import type { NextPage } from 'next'
|
||||
import React from 'react'
|
||||
|
||||
interface PageParams {
|
||||
params: NoteIdProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a page that contains only the rendered document without an editor or realtime updates.
|
||||
*/
|
||||
const DocumentReadOnlyPage: NextPage<PageParams> = ({ params }) => {
|
||||
useNoteAndAppTitle()
|
||||
|
||||
return (
|
||||
<EditorToRendererCommunicatorContextProvider>
|
||||
<NoteLoadingBoundary noteId={params.noteId}>
|
||||
<div className={'d-flex flex-column mvh-100'}>
|
||||
<BaseAppBar />
|
||||
<DocumentReadOnlyPageContent />
|
||||
</div>
|
||||
</NoteLoadingBoundary>
|
||||
</EditorToRendererCommunicatorContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocumentReadOnlyPage
|
42
frontend/src/app/(render)/global-error.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
'use client'
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { UiIcon } from '../../components/common/icons/ui-icon'
|
||||
import { ExternalLink } from '../../components/common/links/external-link'
|
||||
import links from '../../links.json'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Button, Container } from 'react-bootstrap'
|
||||
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons'
|
||||
|
||||
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<Container className='d-flex flex-column mvh-100'>
|
||||
<div className='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={reset} title={'Reload App'} className={'mt-4'}>
|
||||
<UiIcon icon={IconArrowRepeat} />
|
||||
Reload App
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
35
frontend/src/app/(render)/layout.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import '../../../global-styles/index.scss'
|
||||
import { ApplicationLoader } from '../../components/application-loader/application-loader'
|
||||
import { BaseUrlContextProvider } from '../../components/common/base-url/base-url-context-provider'
|
||||
import { FrontendConfigContextProvider } from '../../components/common/frontend-config-context/frontend-config-context-provider'
|
||||
import { ExpectedOriginBoundary } from '../../components/layout/expected-origin-boundary'
|
||||
import { StoreProvider } from '../../redux/store-provider'
|
||||
import { baseUrlFromEnvExtractor } from '../../utils/base-url-from-env-extractor'
|
||||
import React from 'react'
|
||||
import { getConfig } from '../../api/config'
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls()
|
||||
const frontendConfig = await getConfig(baseUrls.renderer)
|
||||
|
||||
return (
|
||||
<html lang='en'>
|
||||
<body>
|
||||
<ExpectedOriginBoundary expectedOrigin={baseUrls.renderer}>
|
||||
<BaseUrlContextProvider baseUrls={baseUrls}>
|
||||
<FrontendConfigContextProvider config={frontendConfig}>
|
||||
<StoreProvider>
|
||||
<ApplicationLoader>{children}</ApplicationLoader>
|
||||
</StoreProvider>
|
||||
</FrontendConfigContextProvider>
|
||||
</BaseUrlContextProvider>
|
||||
</ExpectedOriginBoundary>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
|
@ -1,17 +1,18 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { RendererToEditorCommunicatorContextProvider } from '../components/editor-page/render-context/renderer-to-editor-communicator-context-provider'
|
||||
import { RenderPageContent } from '../components/render-page/render-page-content'
|
||||
import { RendererToEditorCommunicatorContextProvider } from '../../../components/editor-page/render-context/renderer-to-editor-communicator-context-provider'
|
||||
import { RenderPageContent } from '../../../components/render-page/render-page-content'
|
||||
import type { NextPage } from 'next'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Renders the actual markdown renderer that receives the content and metadata via iframe communication.
|
||||
*/
|
||||
export const RenderPage: NextPage = () => {
|
||||
const RenderPage: NextPage = () => {
|
||||
return (
|
||||
<RendererToEditorCommunicatorContextProvider>
|
||||
<RenderPageContent />
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
3
frontend/src/app/apple-icon.png.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: LicenseRef-HedgeDoc-Icon-Usage-Guidelines
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
3
frontend/src/app/favicon.ico.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: LicenseRef-HedgeDoc-Icon-Usage-Guidelines
|
BIN
frontend/src/app/icon.png
Normal file
After Width: | Height: | Size: 9.2 KiB |
3
frontend/src/app/icon.png.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: LicenseRef-HedgeDoc-Icon-Usage-Guidelines
|
|
@ -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
|
||||
if (!searchParams || !pathname) {
|
||||
return
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
shallow: true
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
.catch(() => logger.error("Can't update route"))
|
||||
shouldUpdate = true
|
||||
}
|
||||
}, [state, router, urlParameterSearch, urlParameterSelectedTags])
|
||||
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
|
@ -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
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useDarkModeState } from './use-dark-mode-state'
|
||||
import { useDarkModeState } from '../../../hooks/dark-mode/use-dark-mode-state'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
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'
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { isMockMode } from '../utils/test-modes'
|
||||
import { isMockMode, isTestMode } from '../utils/test-modes'
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
export enum HttpMethod {
|
||||
|
@ -22,6 +22,7 @@ export enum HttpMethod {
|
|||
* @param res The response object.
|
||||
* @param response The response data that will be returned when the HTTP method was the expected one.
|
||||
* @param statusCode The status code with which the response will be sent.
|
||||
* @param respondMethodNotAllowedOnMismatch If set and the method can't process the request then a 405 will be returned. Used for chaining multiple calls together.
|
||||
* @return {@link true} if the HTTP method of the request is the expected one, {@link false} otherwise.
|
||||
*/
|
||||
export const respondToMatchingRequest = <T>(
|
||||
|
@ -29,17 +30,42 @@ export const respondToMatchingRequest = <T>(
|
|||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
response: T,
|
||||
statusCode = 200
|
||||
statusCode = 200,
|
||||
respondMethodNotAllowedOnMismatch = true
|
||||
): boolean => {
|
||||
if (!isMockMode) {
|
||||
res.status(404).send('Mock API is disabled')
|
||||
return false
|
||||
}
|
||||
if (method !== req.method) {
|
||||
res.status(405).send('Method not allowed')
|
||||
return false
|
||||
} else {
|
||||
} else if (method === req.method) {
|
||||
res.status(statusCode).json(response)
|
||||
return true
|
||||
} else if (respondMethodNotAllowedOnMismatch) {
|
||||
res.status(405).send('Method not allowed')
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts a mock HTTP request that is only allowed in test mode.
|
||||
* Such requests can only be issued from localhost and only if mock API is activated.
|
||||
*
|
||||
* @param req The request object.
|
||||
* @param res The response object.
|
||||
* @param response The response data that will be returned when the HTTP method was the expected one.
|
||||
*/
|
||||
export const respondToTestRequest = <T>(req: NextApiRequest, res: NextApiResponse, response: () => T): boolean => {
|
||||
if (!isMockMode) {
|
||||
res.status(404).send('Mock API is disabled')
|
||||
} else if (req.method !== HttpMethod.POST) {
|
||||
res.status(405).send('Method not allowed')
|
||||
} else if (!isTestMode) {
|
||||
res.status(404).send('Route only available in test mode')
|
||||
} else if (req.socket.remoteAddress !== '127.0.0.1' && req.socket.remoteAddress !== '::1') {
|
||||
res.status(403).send('Request must come from localhost')
|
||||
} else {
|
||||
res.status(200).json(response())
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useRouter } from 'next/router'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
|
@ -13,10 +13,9 @@ import { useMemo } from 'react'
|
|||
* @return An array of values extracted from the router.
|
||||
*/
|
||||
export const useArrayStringUrlParameter = (parameter: string): string[] => {
|
||||
const router = useRouter()
|
||||
const router = useSearchParams()
|
||||
|
||||
return useMemo(() => {
|
||||
const value = router.query[parameter]
|
||||
return (typeof value === 'string' ? [value] : value) ?? []
|
||||
}, [parameter, router.query])
|
||||
return router?.getAll(parameter) ?? []
|
||||
}, [parameter, router])
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { baseUrlContext } from '../../components/common/base-url/base-url-context-provider'
|
||||
import { useRouter } from 'next/router'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useContext, useMemo } from 'react'
|
||||
|
||||
export enum ORIGIN {
|
||||
|
@ -22,11 +22,11 @@ export const useBaseUrl = (origin = ORIGIN.CURRENT_PAGE): string => {
|
|||
throw new Error('No base url context received. Did you forget to use the provider component?')
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = usePathname()
|
||||
|
||||
return useMemo(() => {
|
||||
return (router.route === '/render' && origin === ORIGIN.CURRENT_PAGE) || origin === ORIGIN.RENDERER
|
||||
return (route === '/render' && origin === ORIGIN.CURRENT_PAGE) || origin === ORIGIN.RENDERER
|
||||
? baseUrls.renderer
|
||||
: baseUrls.editor
|
||||
}, [origin, baseUrls.renderer, baseUrls.editor, router.route])
|
||||
}, [origin, baseUrls.renderer, baseUrls.editor, route])
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useRouter } from 'next/router'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
|
@ -14,10 +14,9 @@ import { useMemo } from 'react'
|
|||
* @return A value extracted from the router.
|
||||
*/
|
||||
export const useSingleStringUrlParameter = <T>(parameter: string, fallback: T): string | T => {
|
||||
const router = useRouter()
|
||||
const router = useSearchParams()
|
||||
|
||||
return useMemo(() => {
|
||||
const value = router.query[parameter]
|
||||
return (typeof value === 'string' ? value : value?.[0]) ?? fallback
|
||||
}, [fallback, parameter, router.query])
|
||||
return router?.get(parameter) ?? fallback
|
||||
}, [fallback, parameter, router])
|
||||
}
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { getNote } from '../api/notes'
|
||||
import { Redirect } from '../components/common/redirect'
|
||||
import { useSingleStringUrlParameter } from '../hooks/common/use-single-string-url-parameter'
|
||||
import Custom404 from './404'
|
||||
import type { NextPage } from 'next'
|
||||
import React from 'react'
|
||||
import { useAsync } from 'react-use'
|
||||
|
||||
/**
|
||||
* Redirects the user to the editor if the link is a root level direct link to a version 1 note.
|
||||
*/
|
||||
export const DirectLinkFallback: NextPage = () => {
|
||||
const id = useSingleStringUrlParameter('id', undefined)
|
||||
|
||||
const { error, value } = useAsync(async () => {
|
||||
if (id === undefined) {
|
||||
throw new Error('No note id found in path')
|
||||
}
|
||||
const noteData = await getNote(id)
|
||||
if (noteData.metadata.version !== 1) {
|
||||
throw new Error('Note is not a version 1 note')
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
if (error !== undefined) {
|
||||
return <Custom404 />
|
||||
} else if (value !== undefined) {
|
||||
return <Redirect to={`/n/${value}`} />
|
||||
} else {
|
||||
return <span>Loading</span>
|
||||
}
|
||||
}
|
||||
|
||||
export default DirectLinkFallback
|
|
@ -1,76 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import '../../global-styles/index.scss'
|
||||
import type { FrontendConfig } from '../api/config/types'
|
||||
import { ApplicationLoader } from '../components/application-loader/application-loader'
|
||||
import type { BaseUrls } from '../components/common/base-url/base-url-context-provider'
|
||||
import { BaseUrlContextProvider } from '../components/common/base-url/base-url-context-provider'
|
||||
import { FrontendConfigContextProvider } from '../components/common/frontend-config-context/frontend-config-context-provider'
|
||||
import { ErrorBoundary } from '../components/error-boundary/error-boundary'
|
||||
import { BaseHead } from '../components/layout/base-head'
|
||||
import { UiNotificationBoundary } from '../components/notifications/ui-notification-boundary'
|
||||
import { StoreProvider } from '../redux/store-provider'
|
||||
import { BaseUrlFromEnvExtractor } from '../utils/base-url-from-env-extractor'
|
||||
import { configureLuxon } from '../utils/configure-luxon'
|
||||
import { determineCurrentOrigin } from '../utils/determine-current-origin'
|
||||
import { ExpectedOriginBoundary } from '../utils/expected-origin-boundary'
|
||||
import { FrontendConfigFetcher } from '../utils/frontend-config-fetcher'
|
||||
import { isTestMode } from '../utils/test-modes'
|
||||
import type { AppContext, AppInitialProps, AppProps } from 'next/app'
|
||||
import React from 'react'
|
||||
|
||||
configureLuxon()
|
||||
|
||||
interface AppPageProps {
|
||||
baseUrls: BaseUrls | undefined
|
||||
frontendConfig: FrontendConfig | undefined
|
||||
currentOrigin: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual hedgedoc next js app.
|
||||
* Provides necessary wrapper components to every page.
|
||||
*/
|
||||
function HedgeDocApp({ Component, pageProps }: AppProps<AppPageProps>) {
|
||||
return (
|
||||
<BaseUrlContextProvider baseUrls={pageProps.baseUrls}>
|
||||
<FrontendConfigContextProvider config={pageProps.frontendConfig}>
|
||||
<ExpectedOriginBoundary currentOrigin={pageProps.currentOrigin}>
|
||||
<StoreProvider>
|
||||
<BaseHead />
|
||||
<ApplicationLoader>
|
||||
<ErrorBoundary>
|
||||
<UiNotificationBoundary>
|
||||
<Component {...pageProps} />
|
||||
</UiNotificationBoundary>
|
||||
</ErrorBoundary>
|
||||
</ApplicationLoader>
|
||||
</StoreProvider>
|
||||
</ExpectedOriginBoundary>
|
||||
</FrontendConfigContextProvider>
|
||||
</BaseUrlContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
||||
const frontendConfigFetcher = new FrontendConfigFetcher()
|
||||
|
||||
HedgeDocApp.getInitialProps = async ({ ctx }: AppContext): Promise<AppInitialProps<AppPageProps>> => {
|
||||
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls().orElse(undefined)
|
||||
const frontendConfig = isTestMode ? undefined : await frontendConfigFetcher.fetch(baseUrls) //some tests mock the frontend config. Therefore it needs to be fetched in the browser.
|
||||
const currentOrigin = determineCurrentOrigin(ctx)
|
||||
|
||||
return {
|
||||
pageProps: {
|
||||
baseUrls,
|
||||
frontendConfig,
|
||||
currentOrigin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export default HedgeDocApp
|
|
@ -5,53 +5,17 @@
|
|||
*/
|
||||
import type { FrontendConfig } from '../../../api/config/types'
|
||||
import { AuthProviderType } from '../../../api/config/types'
|
||||
import { HttpMethod, respondToMatchingRequest } from '../../../handler-utils/respond-to-matching-request'
|
||||
import {
|
||||
HttpMethod,
|
||||
respondToMatchingRequest,
|
||||
respondToTestRequest
|
||||
} from '../../../handler-utils/respond-to-matching-request'
|
||||
import { isTestMode } from '../../../utils/test-modes'
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
respondToMatchingRequest<FrontendConfig>(HttpMethod.GET, req, res, {
|
||||
const initialConfig: FrontendConfig = {
|
||||
allowAnonymous: true,
|
||||
allowRegister: true,
|
||||
authProviders: [
|
||||
{
|
||||
type: AuthProviderType.LOCAL
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.LDAP,
|
||||
identifier: 'test-ldap',
|
||||
providerName: 'Test LDAP'
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.DROPBOX
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.FACEBOOK
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.GITHUB
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.GITLAB,
|
||||
identifier: 'test-gitlab',
|
||||
providerName: 'Test GitLab'
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.GOOGLE
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.OAUTH2,
|
||||
identifier: 'test-oauth2',
|
||||
providerName: 'Test OAuth2'
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.SAML,
|
||||
identifier: 'test-saml',
|
||||
providerName: 'Test SAML'
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.TWITTER
|
||||
}
|
||||
],
|
||||
branding: {
|
||||
name: 'DEMO Corp',
|
||||
logo: '/public/img/demo.png'
|
||||
|
@ -63,13 +27,66 @@ const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
|||
imprint: 'https://example.com/imprint'
|
||||
},
|
||||
version: {
|
||||
major: 2,
|
||||
major: isTestMode ? 0 : 2,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
preRelease: isTestMode ? undefined : '',
|
||||
commit: 'mock'
|
||||
},
|
||||
plantumlServer: 'https://www.plantuml.com/plantuml',
|
||||
maxDocumentLength: 1000000
|
||||
plantumlServer: isTestMode ? 'http://mock-plantuml.local' : 'https://www.plantuml.com/plantuml',
|
||||
maxDocumentLength: isTestMode ? 200 : 1000000,
|
||||
authProviders: [
|
||||
{
|
||||
type: AuthProviderType.LOCAL
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.FACEBOOK
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.GITHUB
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.TWITTER
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.DROPBOX
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.GOOGLE
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.LDAP,
|
||||
identifier: 'test-ldap',
|
||||
providerName: 'Test LDAP'
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.GITLAB,
|
||||
identifier: 'test-gitlab',
|
||||
providerName: 'Test GitLab'
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.OAUTH2,
|
||||
identifier: 'test-oauth2',
|
||||
providerName: 'Test OAuth2'
|
||||
},
|
||||
{
|
||||
type: AuthProviderType.SAML,
|
||||
identifier: 'test-saml',
|
||||
providerName: 'Test SAML'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
let currentConfig: FrontendConfig = initialConfig
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
respondToMatchingRequest<FrontendConfig>(HttpMethod.GET, req, res, currentConfig, 200, false) ||
|
||||
respondToTestRequest<FrontendConfig>(req, res, () => {
|
||||
currentConfig = {
|
||||
...initialConfig,
|
||||
...(req.body as FrontendConfig)
|
||||
}
|
||||
return currentConfig
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary'
|
||||
import { EditorPageContent } from '../../components/editor-page/editor-page-content'
|
||||
import { EditorToRendererCommunicatorContextProvider } from '../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||
import { ResetRealtimeStateBoundary } from '../../components/editor-page/reset-realtime-state-boundary'
|
||||
import { useApplyDarkModeStyle } from '../../hooks/dark-mode/use-apply-dark-mode-style'
|
||||
import type { NextPage } from 'next'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Renders a page that is used by the user to edit markdown notes. It contains the editor and a renderer.
|
||||
*/
|
||||
export const EditorPage: NextPage = () => {
|
||||
useApplyDarkModeStyle()
|
||||
|
||||
return (
|
||||
<ResetRealtimeStateBoundary>
|
||||
<NoteLoadingBoundary>
|
||||
<EditorToRendererCommunicatorContextProvider>
|
||||
<EditorPageContent />
|
||||
</EditorToRendererCommunicatorContextProvider>
|
||||
</NoteLoadingBoundary>
|
||||
</ResetRealtimeStateBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorPage
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary'
|
||||
import { HeadMetaProperties } from '../../components/editor-page/head-meta-properties/head-meta-properties'
|
||||
import { EditorToRendererCommunicatorContextProvider } from '../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||
import { SlideShowPageContent } from '../../components/slide-show-page/slide-show-page-content'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Renders a page that is used by the user to hold a presentation. It contains the renderer for the presentation.
|
||||
*/
|
||||
export const SlideShowPage: React.FC = () => {
|
||||
return (
|
||||
<NoteLoadingBoundary>
|
||||
<HeadMetaProperties />
|
||||
<EditorToRendererCommunicatorContextProvider>
|
||||
<SlideShowPageContent />
|
||||
</EditorToRendererCommunicatorContextProvider>
|
||||
</NoteLoadingBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlideShowPage
|
|
@ -1,37 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary'
|
||||
import { DocumentReadOnlyPageContent } from '../../components/document-read-only-page/document-read-only-page-content'
|
||||
import { HeadMetaProperties } from '../../components/editor-page/head-meta-properties/head-meta-properties'
|
||||
import { EditorToRendererCommunicatorContextProvider } from '../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||
import { MotdModal } from '../../components/global-dialogs/motd-modal/motd-modal'
|
||||
import { BaseAppBar } from '../../components/layout/app-bar/base-app-bar'
|
||||
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 React from 'react'
|
||||
|
||||
/**
|
||||
* Renders a page that contains only the rendered document without an editor or realtime updates.
|
||||
*/
|
||||
export const DocumentReadOnlyPage: React.FC = () => {
|
||||
useApplyDarkModeStyle()
|
||||
useSaveDarkModePreferenceToLocalStorage()
|
||||
|
||||
return (
|
||||
<EditorToRendererCommunicatorContextProvider>
|
||||
<NoteLoadingBoundary>
|
||||
<HeadMetaProperties />
|
||||
<MotdModal />
|
||||
<div className={'d-flex flex-column mvh-100'}>
|
||||
<BaseAppBar />
|
||||
<DocumentReadOnlyPageContent />
|
||||
</div>
|
||||
</NoteLoadingBoundary>
|
||||
</EditorToRendererCommunicatorContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocumentReadOnlyPage
|
|
@ -1,3 +1,4 @@
|
|||
'use client'
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
|
|
|
@ -9,10 +9,9 @@ describe('BaseUrlFromEnvExtractor', () => {
|
|||
it('should return the base urls if both are valid urls', () => {
|
||||
process.env.HD_BASE_URL = 'https://editor.example.org/'
|
||||
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/'
|
||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
||||
const result = baseUrlFromEnvExtractor.extractBaseUrls()
|
||||
expect(result.isPresent()).toBeTruthy()
|
||||
expect(result.get()).toStrictEqual({
|
||||
const sut = new BaseUrlFromEnvExtractor()
|
||||
|
||||
expect(sut.extractBaseUrls()).toStrictEqual({
|
||||
renderer: 'https://renderer.example.org/',
|
||||
editor: 'https://editor.example.org/'
|
||||
})
|
||||
|
@ -21,31 +20,33 @@ describe('BaseUrlFromEnvExtractor', () => {
|
|||
it('should return an empty optional if no var is set', () => {
|
||||
process.env.HD_BASE_URL = undefined
|
||||
process.env.HD_RENDERER_BASE_URL = undefined
|
||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
||||
expect(baseUrlFromEnvExtractor.extractBaseUrls().isEmpty()).toBeTruthy()
|
||||
const sut = new BaseUrlFromEnvExtractor()
|
||||
|
||||
expect(() => sut.extractBaseUrls()).toThrow()
|
||||
})
|
||||
|
||||
it("should return an empty optional if editor base url isn't an URL", () => {
|
||||
process.env.HD_BASE_URL = 'bibedibabedibu'
|
||||
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/'
|
||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
||||
expect(baseUrlFromEnvExtractor.extractBaseUrls().isEmpty()).toBeTruthy()
|
||||
const sut = new BaseUrlFromEnvExtractor()
|
||||
|
||||
expect(() => sut.extractBaseUrls()).toThrow()
|
||||
})
|
||||
|
||||
it("should return an empty optional if renderer base url isn't an URL", () => {
|
||||
process.env.HD_BASE_URL = 'https://editor.example.org/'
|
||||
process.env.HD_RENDERER_BASE_URL = 'bibedibabedibu'
|
||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
||||
expect(baseUrlFromEnvExtractor.extractBaseUrls().isEmpty()).toBeTruthy()
|
||||
const sut = new BaseUrlFromEnvExtractor()
|
||||
|
||||
expect(() => sut.extractBaseUrls()).toThrow()
|
||||
})
|
||||
|
||||
it("should return an optional if editor base url isn't ending with a slash", () => {
|
||||
process.env.HD_BASE_URL = 'https://editor.example.org'
|
||||
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/'
|
||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
||||
const result = baseUrlFromEnvExtractor.extractBaseUrls()
|
||||
expect(result.isPresent()).toBeTruthy()
|
||||
expect(result.get()).toStrictEqual({
|
||||
const sut = new BaseUrlFromEnvExtractor()
|
||||
|
||||
expect(sut.extractBaseUrls()).toStrictEqual({
|
||||
renderer: 'https://renderer.example.org/',
|
||||
editor: 'https://editor.example.org/'
|
||||
})
|
||||
|
@ -54,10 +55,9 @@ describe('BaseUrlFromEnvExtractor', () => {
|
|||
it("should return an optional if renderer base url isn't ending with a slash", () => {
|
||||
process.env.HD_BASE_URL = 'https://editor.example.org/'
|
||||
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org'
|
||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
||||
const result = baseUrlFromEnvExtractor.extractBaseUrls()
|
||||
expect(result.isPresent()).toBeTruthy()
|
||||
expect(result.get()).toStrictEqual({
|
||||
const sut = new BaseUrlFromEnvExtractor()
|
||||
|
||||
expect(sut.extractBaseUrls()).toStrictEqual({
|
||||
renderer: 'https://renderer.example.org/',
|
||||
editor: 'https://editor.example.org/'
|
||||
})
|
||||
|
@ -66,10 +66,9 @@ describe('BaseUrlFromEnvExtractor', () => {
|
|||
it('should copy editor base url to renderer base url if renderer base url is omitted', () => {
|
||||
process.env.HD_BASE_URL = 'https://editor.example.org/'
|
||||
delete process.env.HD_RENDERER_BASE_URL
|
||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
||||
const result = baseUrlFromEnvExtractor.extractBaseUrls()
|
||||
expect(result.isPresent()).toBeTruthy()
|
||||
expect(result.get()).toStrictEqual({
|
||||
const sut = new BaseUrlFromEnvExtractor()
|
||||
|
||||
expect(sut.extractBaseUrls()).toStrictEqual({
|
||||
renderer: 'https://editor.example.org/',
|
||||
editor: 'https://editor.example.org/'
|
||||
})
|
||||
|
|
|
@ -5,15 +5,15 @@
|
|||
*/
|
||||
import type { BaseUrls } from '../components/common/base-url/base-url-context-provider'
|
||||
import { Logger } from './logger'
|
||||
import { isTestMode } from './test-modes'
|
||||
import { isTestMode, isBuildTime } from './test-modes'
|
||||
import { NoSubdirectoryAllowedError, parseUrl } from '@hedgedoc/commons'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
|
||||
/**
|
||||
* Extracts the editor and renderer base urls from the environment variables.
|
||||
* Extracts and caches the editor and renderer base urls from the environment variables.
|
||||
*/
|
||||
export class BaseUrlFromEnvExtractor {
|
||||
private baseUrls: Optional<BaseUrls> | undefined
|
||||
private baseUrls: BaseUrls | undefined
|
||||
private readonly logger = new Logger('Base URL Configuration')
|
||||
|
||||
private extractUrlFromEnvVar(envVarName: string, envVarValue: string | undefined): Optional<URL> {
|
||||
|
@ -51,8 +51,9 @@ export class BaseUrlFromEnvExtractor {
|
|||
return this.extractUrlFromEnvVar('HD_RENDERER_BASE_URL', process.env.HD_RENDERER_BASE_URL)
|
||||
}
|
||||
|
||||
private renewBaseUrls(): void {
|
||||
this.baseUrls = this.extractEditorBaseUrlFromEnv().flatMap((editorBaseUrl) =>
|
||||
private renewBaseUrls(): BaseUrls {
|
||||
return this.extractEditorBaseUrlFromEnv()
|
||||
.flatMap((editorBaseUrl) =>
|
||||
this.extractRendererBaseUrlFromEnv(editorBaseUrl).map((rendererBaseUrl) => {
|
||||
return {
|
||||
editor: editorBaseUrl.toString(),
|
||||
|
@ -60,14 +61,7 @@ export class BaseUrlFromEnvExtractor {
|
|||
}
|
||||
})
|
||||
)
|
||||
this.baseUrls.ifPresent((urls) => {
|
||||
this.logger.info('Editor base URL', urls.editor.toString())
|
||||
this.logger.info('Renderer base URL', urls.renderer.toString())
|
||||
})
|
||||
}
|
||||
|
||||
private isEnvironmentExtractDone(): boolean {
|
||||
return this.baseUrls !== undefined
|
||||
.orElseThrow(() => new Error('couldnt parse env vars'))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -75,10 +69,28 @@ export class BaseUrlFromEnvExtractor {
|
|||
*
|
||||
* @return An {@link Optional} with the base urls.
|
||||
*/
|
||||
public extractBaseUrls(): Optional<BaseUrls> {
|
||||
if (!this.isEnvironmentExtractDone()) {
|
||||
this.renewBaseUrls()
|
||||
public extractBaseUrls(): BaseUrls {
|
||||
if (isBuildTime) {
|
||||
return {
|
||||
editor: 'https://example.org/',
|
||||
renderer: 'https://example.org/'
|
||||
}
|
||||
return Optional.ofNullable(this.baseUrls).flatMap((value) => value)
|
||||
}
|
||||
|
||||
if (this.baseUrls === undefined) {
|
||||
this.baseUrls = this.renewBaseUrls()
|
||||
this.logBaseUrls()
|
||||
}
|
||||
return this.baseUrls
|
||||
}
|
||||
|
||||
private logBaseUrls() {
|
||||
if (this.baseUrls === undefined) {
|
||||
return
|
||||
}
|
||||
this.logger.info('Editor base URL', this.baseUrls.editor.toString())
|
||||
this.logger.info('Renderer base URL', this.baseUrls.renderer.toString())
|
||||
}
|
||||
}
|
||||
|
||||
export const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
||||
|
|
|
@ -1,151 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { determineCurrentOrigin } from './determine-current-origin'
|
||||
import * as IsClientSideRenderingModule from './is-client-side-rendering'
|
||||
import type { NextPageContext } from 'next'
|
||||
import { Mock } from 'ts-mockery'
|
||||
|
||||
jest.mock('./is-client-side-rendering')
|
||||
describe('determineCurrentOrigin', () => {
|
||||
describe('client side', () => {
|
||||
it('parses a client side origin correctly', () => {
|
||||
jest.spyOn(IsClientSideRenderingModule, 'isClientSideRendering').mockImplementation(() => true)
|
||||
const expectedOrigin = 'expectedOrigin'
|
||||
Object.defineProperty(window, 'location', { value: { origin: expectedOrigin } })
|
||||
expect(determineCurrentOrigin(Mock.of<NextPageContext>({}))).toBe(expectedOrigin)
|
||||
})
|
||||
})
|
||||
|
||||
describe('server side', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(IsClientSideRenderingModule, 'isClientSideRendering').mockImplementation(() => false)
|
||||
})
|
||||
|
||||
it("won't return an origin if no request is present", () => {
|
||||
expect(determineCurrentOrigin(Mock.of<NextPageContext>({}))).toBeUndefined()
|
||||
})
|
||||
|
||||
it("won't return an origin if no headers are present", () => {
|
||||
expect(determineCurrentOrigin(Mock.of<NextPageContext>({ req: { headers: undefined } }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it("won't return an origin if no host is present", () => {
|
||||
expect(
|
||||
determineCurrentOrigin(
|
||||
Mock.of<NextPageContext>({
|
||||
req: {
|
||||
headers: {}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('will return an origin for a forwarded host', () => {
|
||||
expect(
|
||||
determineCurrentOrigin(
|
||||
Mock.of<NextPageContext>({
|
||||
req: {
|
||||
headers: {
|
||||
'x-forwarded-host': 'forwardedMockHost',
|
||||
'x-forwarded-proto': 'mockProtocol'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe('mockProtocol://forwardedMockHost')
|
||||
})
|
||||
|
||||
it("will fallback to host header if x-forwarded-host isn't present", () => {
|
||||
expect(
|
||||
determineCurrentOrigin(
|
||||
Mock.of<NextPageContext>({
|
||||
req: {
|
||||
headers: {
|
||||
host: 'mockHost',
|
||||
'x-forwarded-proto': 'mockProtocol'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe('mockProtocol://mockHost')
|
||||
})
|
||||
|
||||
it('will prefer x-forwarded-host over host', () => {
|
||||
expect(
|
||||
determineCurrentOrigin(
|
||||
Mock.of<NextPageContext>({
|
||||
req: {
|
||||
headers: {
|
||||
'x-forwarded-host': 'forwardedMockHost',
|
||||
host: 'mockHost',
|
||||
'x-forwarded-proto': 'mockProtocol'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe('mockProtocol://forwardedMockHost')
|
||||
})
|
||||
|
||||
it('will fallback to http if x-forwarded-proto is missing', () => {
|
||||
expect(
|
||||
determineCurrentOrigin(
|
||||
Mock.of<NextPageContext>({
|
||||
req: {
|
||||
headers: {
|
||||
'x-forwarded-host': 'forwardedMockHost'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe('http://forwardedMockHost')
|
||||
})
|
||||
|
||||
it('will use the first header if x-forwarded-proto is defined multiple times', () => {
|
||||
expect(
|
||||
determineCurrentOrigin(
|
||||
Mock.of<NextPageContext>({
|
||||
req: {
|
||||
headers: {
|
||||
'x-forwarded-proto': ['mockProtocol1', 'mockProtocol2'],
|
||||
'x-forwarded-host': 'forwardedMockHost'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe('mockProtocol1://forwardedMockHost')
|
||||
})
|
||||
|
||||
it('will use the first header if x-forwarded-host is defined multiple times', () => {
|
||||
expect(
|
||||
determineCurrentOrigin(
|
||||
Mock.of<NextPageContext>({
|
||||
req: {
|
||||
headers: {
|
||||
'x-forwarded-host': ['forwardedMockHost1', 'forwardedMockHost2']
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe('http://forwardedMockHost1')
|
||||
})
|
||||
|
||||
it('will use the first value if x-forwarded-proto is a comma separated list', () => {
|
||||
expect(
|
||||
determineCurrentOrigin(
|
||||
Mock.of<NextPageContext>({
|
||||
req: {
|
||||
headers: {
|
||||
'x-forwarded-proto': 'mockProtocol1,mockProtocol2',
|
||||
'x-forwarded-host': 'forwardedMockHost'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe('mockProtocol1://forwardedMockHost')
|
||||
})
|
||||
})
|
||||
})
|