feat: migrate frontend app to nextjs app router
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
|
@ -19,7 +19,7 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🦔 > Building"
|
echo "🦔 > Building"
|
||||||
next build
|
BUILD_TIME=true next build
|
||||||
|
|
||||||
echo "🦔 > Bundling"
|
echo "🦔 > Bundling"
|
||||||
mv .next/standalone dist
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { AuthProviderType } from '../../src/api/config/types'
|
import { AuthProviderType } from '../../src/api/config/types'
|
||||||
|
import { HttpMethod } from '../../src/handler-utils/respond-to-matching-request'
|
||||||
|
|
||||||
declare namespace Cypress {
|
declare namespace Cypress {
|
||||||
interface Chainable {
|
interface Chainable {
|
||||||
|
@ -80,13 +81,7 @@ export const config = {
|
||||||
}
|
}
|
||||||
|
|
||||||
Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => {
|
Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => {
|
||||||
return cy.intercept('/api/private/config', {
|
return cy.request(HttpMethod.POST, '/api/private/config', { ...config, ...additionalConfig })
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
...config,
|
|
||||||
...additionalConfig
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"slogan": "Ideas grow better together",
|
"slogan": "Ideas grow better together",
|
||||||
"title": "Collaborative markdown notes",
|
|
||||||
"icon": "HedgeDoc logo with text"
|
"icon": "HedgeDoc logo with text"
|
||||||
},
|
},
|
||||||
"notificationTest": {
|
"notificationTest": {
|
||||||
|
|
1
frontend/next-env.d.ts
vendored
|
@ -1,5 +1,6 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference types="next/navigation-types/compat/navigation" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 path = require('path')
|
||||||
const CopyWebpackPlugin = require('copy-webpack-plugin')
|
const CopyWebpackPlugin = require('copy-webpack-plugin')
|
||||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
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)
|
console.log('Node environment is', process.env.NODE_ENV)
|
||||||
|
|
||||||
if (isMockMode) {
|
|
||||||
console.log('Use mock API')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTestMode) {
|
if (isTestMode) {
|
||||||
console.warn(`This build runs in test mode. This means:
|
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
|
- 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) {
|
if (isMockMode) {
|
||||||
|
@ -28,7 +26,14 @@ if (isMockMode) {
|
||||||
- No real data. All API responses are mocked
|
- No real data. All API responses are mocked
|
||||||
- No persistent data
|
- No persistent data
|
||||||
- No realtime editing
|
- 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) {
|
if (isProfilingMode) {
|
||||||
|
@ -54,7 +59,6 @@ const svgrConfig = {
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const rawNextConfig = {
|
const rawNextConfig = {
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
|
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.svg$/i,
|
test: /\.svg$/i,
|
||||||
issuer: /\.[jt]sx?$/,
|
issuer: /\.[jt]sx?$/,
|
||||||
|
|
|
@ -5,8 +5,8 @@
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env NODE_ENV=production ./build.sh",
|
"build": "cross-env NODE_ENV=production ./build.sh",
|
||||||
"build:mock": "cross-env NEXT_PUBLIC_USE_MOCK_API=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 NODE_ENV=test NEXT_PUBLIC_TEST_MODE=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",
|
"analyze": "cross-env ANALYZE=true yarn build --profile",
|
||||||
"format": "prettier -c \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"",
|
"format": "prettier -c \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"",
|
||||||
"format:fix": "prettier -w \"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 { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
import type { FrontendConfig } from './types'
|
import type { FrontendConfig } from './types'
|
||||||
|
import { isBuildTime } from '../../utils/test-modes'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the frontend config from the backend.
|
* Fetches the frontend config from the backend.
|
||||||
|
@ -12,7 +13,10 @@ import type { FrontendConfig } from './types'
|
||||||
* @return The frontend config.
|
* @return The frontend config.
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @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()
|
const response = await new GetApiRequestBuilder<FrontendConfig>('config', baseUrl).sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,8 @@ import type { Note, NoteDeletionOptions, NoteMetadata } from './types'
|
||||||
* @return Content and metadata of the specified note.
|
* @return Content and metadata of the specified note.
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
|
export const getNote = async (noteIdOrAlias: string, baseUrl?: string): Promise<Note> => {
|
||||||
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias).sendRequest()
|
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias, baseUrl).sendRequest()
|
||||||
return response.asParsedJsonObject()
|
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-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { CheatsheetContent } from '../components/cheatsheet/cheatsheet-content'
|
import { CheatsheetContent } from '../../../components/cheatsheet/cheatsheet-content'
|
||||||
import { useApplyDarkModeStyle } from '../hooks/dark-mode/use-apply-dark-mode-style'
|
|
||||||
import type { NextPage } from 'next'
|
import type { NextPage } from 'next'
|
||||||
import { Container } from 'react-bootstrap'
|
import { Container } from 'react-bootstrap'
|
||||||
|
|
||||||
const CheatsheetPage: NextPage = () => {
|
const CheatsheetPage: NextPage = () => {
|
||||||
useApplyDarkModeStyle()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<CheatsheetContent></CheatsheetContent>
|
<CheatsheetContent />
|
||||||
</Container>
|
</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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { HistoryContent } from '../components/history-page/history-content/history-content'
|
import { HistoryContent } from '../../../components/history-page/history-content/history-content'
|
||||||
import { HistoryToolbar } from '../components/history-page/history-toolbar/history-toolbar'
|
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 { 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 { HistoryToolbarStateContextProvider } from '../../../components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider'
|
||||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||||
import type { NextPage } from 'next'
|
import type { NextPage } from 'next'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { Row } from 'react-bootstrap'
|
import { Row } from 'react-bootstrap'
|
|
@ -1,15 +1,17 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { CustomBranding } from '../components/common/custom-branding/custom-branding'
|
import { CustomBranding } from '../../../components/common/custom-branding/custom-branding'
|
||||||
import { HedgeDocLogoVertical } from '../components/common/hedge-doc-logo/hedge-doc-logo-vertical'
|
import { HedgeDocLogoVertical } from '../../../components/common/hedge-doc-logo/hedge-doc-logo-vertical'
|
||||||
import { LogoSize } from '../components/common/hedge-doc-logo/logo-size'
|
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 { EditorToRendererCommunicatorContextProvider } from '../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||||
import { CoverButtons } from '../components/intro-page/cover-buttons/cover-buttons'
|
import { CoverButtons } from '../../../components/intro-page/cover-buttons/cover-buttons'
|
||||||
import { IntroCustomContent } from '../components/intro-page/intro-custom-content'
|
import { IntroCustomContent } from '../../../components/intro-page/intro-custom-content'
|
||||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||||
import type { NextPage } from 'next'
|
import type { NextPage } from 'next'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Trans } from 'react-i18next'
|
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-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { AuthProviderWithCustomName } from '../api/config/types'
|
import type { AuthProviderWithCustomName } from '../../../api/config/types'
|
||||||
import { AuthProviderType } from '../api/config/types'
|
import { AuthProviderType } from '../../../api/config/types'
|
||||||
import { useFrontendConfig } from '../components/common/frontend-config-context/use-frontend-config'
|
import { useFrontendConfig } from '../../../components/common/frontend-config-context/use-frontend-config'
|
||||||
import { RedirectBack } from '../components/common/redirect-back'
|
import { RedirectBack } from '../../../components/common/redirect-back'
|
||||||
import { ShowIf } from '../components/common/show-if/show-if'
|
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||||
import { filterOneClickProviders } from '../components/login-page/auth/utils'
|
import { filterOneClickProviders } from '../../../components/login-page/auth/utils'
|
||||||
import { ViaLdap } from '../components/login-page/auth/via-ldap'
|
import { ViaLdap } from '../../../components/login-page/auth/via-ldap'
|
||||||
import { ViaLocal } from '../components/login-page/auth/via-local'
|
import { ViaLocal } from '../../../components/login-page/auth/via-local'
|
||||||
import { ViaOneClick } from '../components/login-page/auth/via-one-click'
|
import { ViaOneClick } from '../../../components/login-page/auth/via-one-click'
|
||||||
import { useApplicationState } from '../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
|
import type { NextPage } from 'next'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { Card, Col, Row } from 'react-bootstrap'
|
import { Card, Col, Row } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
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.
|
* 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.
|
* Redirects the user to the history page if they are already logged in.
|
||||||
*/
|
*/
|
||||||
export const LoginPage: React.FC = () => {
|
const LoginPage: NextPage = () => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
const authProviders = useFrontendConfig().authProviders
|
const authProviders = useFrontendConfig().authProviders
|
||||||
const userLoggedIn = useApplicationState((state) => !!state.user)
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { createNote } from '../api/notes'
|
import { createNote } from '../../../api/notes'
|
||||||
import type { Note } from '../api/notes/types'
|
import type { Note } from '../../../api/notes/types'
|
||||||
import { LoadingScreen } from '../components/application-loader/loading-screen/loading-screen'
|
import { LoadingScreen } from '../../../components/application-loader/loading-screen/loading-screen'
|
||||||
import { CustomAsyncLoadingBoundary } from '../components/common/async-loading-boundary/custom-async-loading-boundary'
|
import { CustomAsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/custom-async-loading-boundary'
|
||||||
import { Redirect } from '../components/common/redirect'
|
import { Redirect } from '../../../components/common/redirect'
|
||||||
import { ShowIf } from '../components/common/show-if/show-if'
|
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||||
import { CommonErrorPage } from '../components/error-pages/common-error-page'
|
import { CommonErrorPage } from '../../../components/error-pages/common-error-page'
|
||||||
import { useSingleStringUrlParameter } from '../hooks/common/use-single-string-url-parameter'
|
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
|
||||||
import type { NextPage } from 'next'
|
import type { NextPage } from 'next'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useAsync } from 'react-use'
|
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.
|
* 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 newContent = useSingleStringUrlParameter('content', '')
|
||||||
|
|
||||||
const { loading, error, value } = useAsync(() => {
|
const { loading, error, value } = useAsync(() => {
|
|
@ -3,14 +3,14 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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'
|
import type { NextPage } from 'next'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a hedgedoc themed 404 page.
|
* Renders a hedgedoc themed 404 page.
|
||||||
*/
|
*/
|
||||||
const Custom404: NextPage = () => {
|
const NotFound: NextPage = () => {
|
||||||
return <CommonErrorPage titleI18nKey={'errors.notFound.title'} descriptionI18nKey={'errors.notFound.description'} />
|
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-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { AuthProviderType } from '../api/config/types'
|
import { AuthProviderType } from '../../../api/config/types'
|
||||||
import { Redirect } from '../components/common/redirect'
|
import { Redirect } from '../../../components/common/redirect'
|
||||||
import { ShowIf } from '../components/common/show-if/show-if'
|
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||||
import { ProfileAccessTokens } from '../components/profile-page/access-tokens/profile-access-tokens'
|
import { ProfileAccessTokens } from '../../../components/profile-page/access-tokens/profile-access-tokens'
|
||||||
import { ProfileAccountManagement } from '../components/profile-page/account-management/profile-account-management'
|
import { ProfileAccountManagement } from '../../../components/profile-page/account-management/profile-account-management'
|
||||||
import { ProfileChangePassword } from '../components/profile-page/settings/profile-change-password'
|
import { ProfileChangePassword } from '../../../components/profile-page/settings/profile-change-password'
|
||||||
import { ProfileDisplayName } from '../components/profile-page/settings/profile-display-name'
|
import { ProfileDisplayName } from '../../../components/profile-page/settings/profile-display-name'
|
||||||
import { useApplicationState } from '../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
|
import type { NextPage } from 'next'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Col, Row } from 'react-bootstrap'
|
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),
|
* Profile page that includes forms for changing display name, password (if internal login is used),
|
||||||
* managing access tokens and deleting the account.
|
* managing access tokens and deleting the account.
|
||||||
*/
|
*/
|
||||||
export const ProfilePage: React.FC = () => {
|
const ProfilePage: NextPage = () => {
|
||||||
const userProvider = useApplicationState((state) => state.user?.authProvider)
|
const userProvider = useApplicationState((state) => state.user?.authProvider)
|
||||||
|
|
||||||
if (!userProvider) {
|
if (!userProvider) {
|
|
@ -1,26 +1,27 @@
|
||||||
|
'use client'
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { doLocalRegister } from '../api/auth/local'
|
import { doLocalRegister } from '../../../api/auth/local'
|
||||||
import type { ApiError } from '../api/common/api-error'
|
import type { ApiError } from '../../../api/common/api-error'
|
||||||
import { DisplayNameField } from '../components/common/fields/display-name-field'
|
import { DisplayNameField } from '../../../components/common/fields/display-name-field'
|
||||||
import { NewPasswordField } from '../components/common/fields/new-password-field'
|
import { NewPasswordField } from '../../../components/common/fields/new-password-field'
|
||||||
import { PasswordAgainField } from '../components/common/fields/password-again-field'
|
import { PasswordAgainField } from '../../../components/common/fields/password-again-field'
|
||||||
import { UsernameLabelField } from '../components/common/fields/username-label-field'
|
import { UsernameLabelField } from '../../../components/common/fields/username-label-field'
|
||||||
import { useFrontendConfig } from '../components/common/frontend-config-context/use-frontend-config'
|
import { useFrontendConfig } from '../../../components/common/frontend-config-context/use-frontend-config'
|
||||||
import { Redirect } from '../components/common/redirect'
|
import { Redirect } from '../../../components/common/redirect'
|
||||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
|
||||||
import { fetchAndSetUser } from '../components/login-page/auth/utils'
|
import { fetchAndSetUser } from '../../../components/login-page/auth/utils'
|
||||||
import { useUiNotifications } from '../components/notifications/ui-notification-boundary'
|
import { useUiNotifications } from '../../../components/notifications/ui-notification-boundary'
|
||||||
import { RegisterError } from '../components/register-page/register-error'
|
import { RegisterError } from '../../../components/register-page/register-error'
|
||||||
import { RegisterInfos } from '../components/register-page/register-infos'
|
import { RegisterInfos } from '../../../components/register-page/register-infos'
|
||||||
import { useApplicationState } from '../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
import { useLowercaseOnInputChange } from '../hooks/common/use-lowercase-on-input-change'
|
import { useLowercaseOnInputChange } from '../../../hooks/common/use-lowercase-on-input-change'
|
||||||
import { useOnInputChange } from '../hooks/common/use-on-input-change'
|
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
||||||
import type { NextPage } from 'next'
|
import type { NextPage } from 'next'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/navigation'
|
||||||
import type { FormEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
import React, { useCallback, useMemo, useState } from 'react'
|
import React, { useCallback, useMemo, useState } from 'react'
|
||||||
import { Button, Card, Col, Form, Row } from 'react-bootstrap'
|
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.
|
* 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()
|
useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const allowRegister = useFrontendConfig().allowRegister
|
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-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { RendererToEditorCommunicatorContextProvider } from '../components/editor-page/render-context/renderer-to-editor-communicator-context-provider'
|
import { RendererToEditorCommunicatorContextProvider } from '../../../components/editor-page/render-context/renderer-to-editor-communicator-context-provider'
|
||||||
import { RenderPageContent } from '../components/render-page/render-page-content'
|
import { RenderPageContent } from '../../../components/render-page/render-page-content'
|
||||||
import type { NextPage } from 'next'
|
import type { NextPage } from 'next'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the actual markdown renderer that receives the content and metadata via iframe communication.
|
* Renders the actual markdown renderer that receives the content and metadata via iframe communication.
|
||||||
*/
|
*/
|
||||||
export const RenderPage: NextPage = () => {
|
const RenderPage: NextPage = () => {
|
||||||
return (
|
return (
|
||||||
<RendererToEditorCommunicatorContextProvider>
|
<RendererToEditorCommunicatorContextProvider>
|
||||||
<RenderPageContent />
|
<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)
|
* 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 { 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 { setDarkModePreference } from '../../../redux/dark-mode/methods'
|
||||||
import { DarkModePreference } from '../../../redux/dark-mode/types'
|
import { DarkModePreference } from '../../../redux/dark-mode/types'
|
||||||
import { isClientSideRendering } from '../../../utils/is-client-side-rendering'
|
|
||||||
import { Logger } from '../../../utils/logger'
|
import { Logger } from '../../../utils/logger'
|
||||||
|
|
||||||
const logger = new Logger('Dark mode initializer')
|
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.
|
* {@link false} if the user doesn't prefer dark mode or if the value couldn't be read from local storage.
|
||||||
*/
|
*/
|
||||||
const fetchDarkModeFromLocalStorage = (): DarkModePreference => {
|
const fetchDarkModeFromLocalStorage = (): DarkModePreference => {
|
||||||
if (!isClientSideRendering()) {
|
|
||||||
return DarkModePreference.AUTO
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const colorScheme = window.localStorage.getItem(DARK_MODE_LOCAL_STORAGE_KEY)
|
const colorScheme = window.localStorage.getItem(DARK_MODE_LOCAL_STORAGE_KEY)
|
||||||
if (colorScheme === 'dark') {
|
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
|
Settings.defaultLocale = i18n.language
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
|
'use client'
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import React, { createContext, useState } from 'react'
|
import React, { createContext } from 'react'
|
||||||
|
|
||||||
export interface BaseUrls {
|
export interface BaseUrls {
|
||||||
renderer: string
|
renderer: string
|
||||||
|
@ -27,10 +28,9 @@ export const BaseUrlContextProvider: React.FC<PropsWithChildren<BaseUrlContextPr
|
||||||
baseUrls,
|
baseUrls,
|
||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
const [baseUrlState] = useState<undefined | BaseUrls>(() => baseUrls)
|
return baseUrls === undefined ? (
|
||||||
return baseUrlState === undefined ? (
|
|
||||||
<span className={'text-white bg-dark'}>HedgeDoc is not configured correctly! Please check the server log.</span>
|
<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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
|
|
||||||
import { Logger } from '../../../../utils/logger'
|
import { Logger } from '../../../../utils/logger'
|
||||||
import { UiIcon } from '../../icons/ui-icon'
|
import { UiIcon } from '../../icons/ui-icon'
|
||||||
import { ShowIf } from '../../show-if/show-if'
|
import { ShowIf } from '../../show-if/show-if'
|
||||||
|
@ -30,7 +29,7 @@ export const CopyableField: React.FC<CopyableFieldProps> = ({ content, shareOrig
|
||||||
useTranslation()
|
useTranslation()
|
||||||
|
|
||||||
const sharingSupported = useMemo(
|
const sharingSupported = useMemo(
|
||||||
() => shareOriginUrl !== undefined && isClientSideRendering() && typeof navigator.share === 'function',
|
() => shareOriginUrl !== undefined && typeof navigator.share === 'function',
|
||||||
[shareOriginUrl]
|
[shareOriginUrl]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
|
|
||||||
import { Logger } from '../../../../utils/logger'
|
import { Logger } from '../../../../utils/logger'
|
||||||
import { ShowIf } from '../../show-if/show-if'
|
import { ShowIf } from '../../show-if/show-if'
|
||||||
import type { ReactElement, RefObject } from 'react'
|
import type { ReactElement, RefObject } from 'react'
|
||||||
|
@ -45,11 +44,6 @@ export const useCopyOverlay = (
|
||||||
}, [reset, showState])
|
}, [reset, showState])
|
||||||
|
|
||||||
const copyToClipboard = useCallback(() => {
|
const copyToClipboard = useCallback(() => {
|
||||||
if (!isClientSideRendering()) {
|
|
||||||
setShowState(SHOW_STATE.ERROR)
|
|
||||||
log.error('Clipboard not available in server side rendering')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof navigator.clipboard === 'undefined') {
|
if (typeof navigator.clipboard === 'undefined') {
|
||||||
setShowState(SHOW_STATE.ERROR)
|
setShowState(SHOW_STATE.ERROR)
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,17 +1,13 @@
|
||||||
|
'use client'
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { getConfig } from '../../../api/config'
|
|
||||||
import type { FrontendConfig } from '../../../api/config/types'
|
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 { frontendConfigContext } from './context'
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const logger = new Logger('FrontendConfigContextProvider')
|
|
||||||
|
|
||||||
interface FrontendConfigContextProviderProps extends PropsWithChildren {
|
interface FrontendConfigContextProviderProps extends PropsWithChildren {
|
||||||
config?: FrontendConfig
|
config?: FrontendConfig
|
||||||
|
@ -24,22 +20,9 @@ interface FrontendConfigContextProviderProps extends PropsWithChildren {
|
||||||
* @param children the react elements to show if the config is valid
|
* @param children the react elements to show if the config is valid
|
||||||
*/
|
*/
|
||||||
export const FrontendConfigContextProvider: React.FC<FrontendConfigContextProviderProps> = ({ config, children }) => {
|
export const FrontendConfigContextProvider: React.FC<FrontendConfigContextProviderProps> = ({ config, children }) => {
|
||||||
const [configState, setConfigState] = useState<undefined | FrontendConfig>(() => config)
|
return config === undefined ? (
|
||||||
|
|
||||||
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 ? (
|
|
||||||
<span className={'text-white bg-dark'}>No frontend config received! Please check the server log.</span>
|
<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>
|
</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`] = `
|
exports[`create non existing note hint shows an error message if note couldn't be created 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
*/
|
*/
|
||||||
import * as createNoteWithPrimaryAliasModule from '../../../api/notes'
|
import * as createNoteWithPrimaryAliasModule from '../../../api/notes'
|
||||||
import type { Note, NoteMetadata } from '../../../api/notes/types'
|
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 { mockI18n } from '../../../test-utils/mock-i18n'
|
||||||
import { CreateNonExistingNoteHint } from './create-non-existing-note-hint'
|
import { CreateNonExistingNoteHint } from './create-non-existing-note-hint'
|
||||||
import { waitForOtherPromisesToFinish } from '@hedgedoc/commons'
|
import { waitForOtherPromisesToFinish } from '@hedgedoc/commons'
|
||||||
|
@ -18,14 +17,6 @@ jest.mock('../../../hooks/common/use-single-string-url-parameter')
|
||||||
describe('create non existing note hint', () => {
|
describe('create non existing note hint', () => {
|
||||||
const mockedNoteId = 'mockedNoteId'
|
const mockedNoteId = 'mockedNoteId'
|
||||||
|
|
||||||
const mockGetNoteIdQueryParameter = () => {
|
|
||||||
const expectedQueryParameter = 'noteId'
|
|
||||||
jest.spyOn(useSingleStringUrlParameterModule, 'useSingleStringUrlParameter').mockImplementation((parameter) => {
|
|
||||||
expect(parameter).toBe(expectedQueryParameter)
|
|
||||||
return mockedNoteId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockCreateNoteWithPrimaryAlias = () => {
|
const mockCreateNoteWithPrimaryAlias = () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(createNoteWithPrimaryAliasModule, 'createNoteWithPrimaryAlias')
|
.spyOn(createNoteWithPrimaryAliasModule, 'createNoteWithPrimaryAlias')
|
||||||
|
@ -59,14 +50,24 @@ describe('create non existing note hint', () => {
|
||||||
jest.resetModules()
|
jest.resetModules()
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
it('renders nothing if no note id has been provided', async () => {
|
||||||
mockGetNoteIdQueryParameter()
|
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 () => {
|
it('renders an button as initial state', async () => {
|
||||||
mockCreateNoteWithPrimaryAlias()
|
mockCreateNoteWithPrimaryAlias()
|
||||||
const onNoteCreatedCallback = jest.fn()
|
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 screen.findByTestId('createNoteMessage')
|
||||||
await waitForOtherPromisesToFinish()
|
await waitForOtherPromisesToFinish()
|
||||||
expect(onNoteCreatedCallback).not.toBeCalled()
|
expect(onNoteCreatedCallback).not.toBeCalled()
|
||||||
|
@ -76,7 +77,11 @@ describe('create non existing note hint', () => {
|
||||||
it('renders a waiting message when button is clicked', async () => {
|
it('renders a waiting message when button is clicked', async () => {
|
||||||
mockCreateNoteWithPrimaryAlias()
|
mockCreateNoteWithPrimaryAlias()
|
||||||
const onNoteCreatedCallback = jest.fn()
|
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')
|
const button = await screen.findByTestId('createNoteButton')
|
||||||
await act<void>(() => {
|
await act<void>(() => {
|
||||||
button.click()
|
button.click()
|
||||||
|
@ -92,7 +97,11 @@ describe('create non existing note hint', () => {
|
||||||
it('shows success message when the note has been created', async () => {
|
it('shows success message when the note has been created', async () => {
|
||||||
mockCreateNoteWithPrimaryAlias()
|
mockCreateNoteWithPrimaryAlias()
|
||||||
const onNoteCreatedCallback = jest.fn()
|
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')
|
const button = await screen.findByTestId('createNoteButton')
|
||||||
await act<void>(() => {
|
await act<void>(() => {
|
||||||
button.click()
|
button.click()
|
||||||
|
@ -108,7 +117,11 @@ describe('create non existing note hint', () => {
|
||||||
it("shows an error message if note couldn't be created", async () => {
|
it("shows an error message if note couldn't be created", async () => {
|
||||||
mockFailingCreateNoteWithPrimaryAlias()
|
mockFailingCreateNoteWithPrimaryAlias()
|
||||||
const onNoteCreatedCallback = jest.fn()
|
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')
|
const button = await screen.findByTestId('createNoteButton')
|
||||||
await act<void>(() => {
|
await act<void>(() => {
|
||||||
button.click()
|
button.click()
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { createNoteWithPrimaryAlias } from '../../../api/notes'
|
import { createNoteWithPrimaryAlias } from '../../../api/notes'
|
||||||
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
|
|
||||||
import { testId } from '../../../utils/test-id'
|
import { testId } from '../../../utils/test-id'
|
||||||
import { UiIcon } from '../icons/ui-icon'
|
import { UiIcon } from '../icons/ui-icon'
|
||||||
import { ShowIf } from '../show-if/show-if'
|
import { ShowIf } from '../show-if/show-if'
|
||||||
|
@ -20,6 +19,7 @@ import { useAsyncFn } from 'react-use'
|
||||||
|
|
||||||
export interface CreateNonExistingNoteHintProps {
|
export interface CreateNonExistingNoteHintProps {
|
||||||
onNoteCreated: () => void
|
onNoteCreated: () => void
|
||||||
|
noteId: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -27,17 +27,16 @@ export interface CreateNonExistingNoteHintProps {
|
||||||
* When the button was clicked it also shows the progress.
|
* When the button was clicked it also shows the progress.
|
||||||
*
|
*
|
||||||
* @param onNoteCreated A function that will be called after the note was created.
|
* @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()
|
useTranslation()
|
||||||
const noteIdFromUrl = useSingleStringUrlParameter('noteId', undefined)
|
|
||||||
|
|
||||||
const [returnState, createNote] = useAsyncFn(async () => {
|
const [returnState, createNote] = useAsyncFn(async () => {
|
||||||
if (noteIdFromUrl === undefined) {
|
if (noteId !== undefined) {
|
||||||
throw new Error('Note id not set')
|
return await createNoteWithPrimaryAlias('', noteId)
|
||||||
}
|
}
|
||||||
return await createNoteWithPrimaryAlias('', noteIdFromUrl)
|
}, [noteId])
|
||||||
}, [noteIdFromUrl])
|
|
||||||
|
|
||||||
const onClickHandler = useCallback(() => {
|
const onClickHandler = useCallback(() => {
|
||||||
void createNote()
|
void createNote()
|
||||||
|
@ -49,7 +48,7 @@ export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps>
|
||||||
}
|
}
|
||||||
}, [onNoteCreated, returnState.value])
|
}, [onNoteCreated, returnState.value])
|
||||||
|
|
||||||
if (noteIdFromUrl === undefined) {
|
if (noteId === undefined) {
|
||||||
return null
|
return null
|
||||||
} else if (returnState.value) {
|
} else if (returnState.value) {
|
||||||
return (
|
return (
|
||||||
|
@ -76,7 +75,7 @@ export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps>
|
||||||
return (
|
return (
|
||||||
<Alert variant={'info'} {...testId('createNoteMessage')} className={'mt-5'}>
|
<Alert variant={'info'} {...testId('createNoteMessage')} className={'mt-5'}>
|
||||||
<span>
|
<span>
|
||||||
<Trans i18nKey={'noteLoadingBoundary.createNote.question'} values={{ aliasName: noteIdFromUrl }} />
|
<Trans i18nKey={'noteLoadingBoundary.createNote.question'} values={{ aliasName: noteId }} />
|
||||||
</span>
|
</span>
|
||||||
<div className={'mt-3'}>
|
<div className={'mt-3'}>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { getNote } from '../../../../api/notes'
|
import { getNote } from '../../../../api/notes'
|
||||||
import { useSingleStringUrlParameter } from '../../../../hooks/common/use-single-string-url-parameter'
|
|
||||||
import { setNoteDataFromServer } from '../../../../redux/note-details/methods'
|
import { setNoteDataFromServer } from '../../../../redux/note-details/methods'
|
||||||
import { useAsyncFn } from 'react-use'
|
import { useAsyncFn } from 'react-use'
|
||||||
import type { AsyncState } from 'react-use/lib/useAsyncFn'
|
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.
|
* @return An {@link AsyncState async state} that represents the current state of the loading process.
|
||||||
*/
|
*/
|
||||||
export const useLoadNoteFromServer = (): [AsyncState<boolean>, () => void] => {
|
export const useLoadNoteFromServer = (noteId: string | undefined): [AsyncState<boolean>, () => void] => {
|
||||||
const id = useSingleStringUrlParameter('noteId', undefined)
|
|
||||||
|
|
||||||
return useAsyncFn(async (): Promise<boolean> => {
|
return useAsyncFn(async (): Promise<boolean> => {
|
||||||
if (id === undefined) {
|
if (noteId === undefined) {
|
||||||
throw new Error('Invalid id')
|
throw new Error('Invalid id')
|
||||||
}
|
}
|
||||||
const noteFromServer = await getNote(id)
|
const noteFromServer = await getNote(noteId)
|
||||||
setNoteDataFromServer(noteFromServer)
|
setNoteDataFromServer(noteFromServer)
|
||||||
return true
|
return true
|
||||||
}, [id])
|
}, [noteId])
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { ApiError } from '../../../api/common/api-error'
|
||||||
import * as getNoteModule from '../../../api/notes'
|
import * as getNoteModule from '../../../api/notes'
|
||||||
import type { Note } from '../../../api/notes/types'
|
import type { Note } from '../../../api/notes/types'
|
||||||
import * as LoadingScreenModule from '../../../components/application-loader/loading-screen/loading-screen'
|
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 * as setNoteDataFromServerModule from '../../../redux/note-details/methods'
|
||||||
import { mockI18n } from '../../../test-utils/mock-i18n'
|
import { mockI18n } from '../../../test-utils/mock-i18n'
|
||||||
import { testId } from '../../../utils/test-id'
|
import { testId } from '../../../utils/test-id'
|
||||||
|
@ -64,17 +63,8 @@ describe('Note loading boundary', () => {
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
mockGetNoteIdQueryParameter()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockGetNoteIdQueryParameter = () => {
|
|
||||||
const expectedQueryParameter = 'noteId'
|
|
||||||
jest.spyOn(useSingleStringUrlParameterModule, 'useSingleStringUrlParameter').mockImplementation((parameter) => {
|
|
||||||
expect(parameter).toBe(expectedQueryParameter)
|
|
||||||
return mockedNoteId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockGetNoteApiCall = (returnValue: Note) => {
|
const mockGetNoteApiCall = (returnValue: Note) => {
|
||||||
jest.spyOn(getNoteModule, 'getNote').mockImplementation((id) => {
|
jest.spyOn(getNoteModule, 'getNote').mockImplementation((id) => {
|
||||||
expect(id).toBe(mockedNoteId)
|
expect(id).toBe(mockedNoteId)
|
||||||
|
@ -105,7 +95,7 @@ describe('Note loading boundary', () => {
|
||||||
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
|
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
|
||||||
|
|
||||||
const view = render(
|
const view = render(
|
||||||
<NoteLoadingBoundary>
|
<NoteLoadingBoundary noteId={mockedNoteId}>
|
||||||
<span data-testid={'success'}>success!</span>
|
<span data-testid={'success'}>success!</span>
|
||||||
</NoteLoadingBoundary>
|
</NoteLoadingBoundary>
|
||||||
)
|
)
|
||||||
|
@ -121,7 +111,7 @@ describe('Note loading boundary', () => {
|
||||||
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
|
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
|
||||||
|
|
||||||
const view = render(
|
const view = render(
|
||||||
<NoteLoadingBoundary>
|
<NoteLoadingBoundary noteId={mockedNoteId}>
|
||||||
<span data-testid={'success'}>success!</span>
|
<span data-testid={'success'}>success!</span>
|
||||||
</NoteLoadingBoundary>
|
</NoteLoadingBoundary>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
'use client'
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
|
@ -17,15 +18,20 @@ import React, { useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
const logger = new Logger('NoteLoadingBoundary')
|
const logger = new Logger('NoteLoadingBoundary')
|
||||||
|
|
||||||
|
export interface NoteIdProps {
|
||||||
|
noteId: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the note identified by the note-id in the URL.
|
* 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.
|
* 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.
|
* 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 }) => {
|
export const NoteLoadingBoundary: React.FC<PropsWithChildren<NoteIdProps>> = ({ children, noteId }) => {
|
||||||
const [{ error, loading, value }, loadNoteFromServer] = useLoadNoteFromServer()
|
const [{ error, loading, value }, loadNoteFromServer] = useLoadNoteFromServer(noteId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNoteFromServer()
|
loadNoteFromServer()
|
||||||
|
@ -46,11 +52,11 @@ export const NoteLoadingBoundary: React.FC<PropsWithChildren> = ({ children }) =
|
||||||
titleI18nKey={`${errorI18nKeyPrefix}.title`}
|
titleI18nKey={`${errorI18nKeyPrefix}.title`}
|
||||||
descriptionI18nKey={`${errorI18nKeyPrefix}.description`}>
|
descriptionI18nKey={`${errorI18nKeyPrefix}.description`}>
|
||||||
<ShowIf condition={error instanceof ApiError && error.statusCode === 404}>
|
<ShowIf condition={error instanceof ApiError && error.statusCode === 404}>
|
||||||
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} />
|
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} noteId={noteId} />
|
||||||
</ShowIf>
|
</ShowIf>
|
||||||
</CommonErrorPage>
|
</CommonErrorPage>
|
||||||
)
|
)
|
||||||
}, [error, loadNoteFromServer])
|
}, [error, loadNoteFromServer, noteId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomAsyncLoadingBoundary
|
<CustomAsyncLoadingBoundary
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/navigation'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -3,10 +3,9 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { Logger } from '../../utils/logger'
|
|
||||||
import { testId } from '../../utils/test-id'
|
import { testId } from '../../utils/test-id'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/navigation'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
|
|
||||||
export interface RedirectProps {
|
export interface RedirectProps {
|
||||||
|
@ -14,8 +13,6 @@ export interface RedirectProps {
|
||||||
replace?: boolean
|
replace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = new Logger('Redirect')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirects the user to another URL. Can be external or internal.
|
* 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()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
;(replace ? router.replace(to) : router.push(to)).catch((error: Error) => {
|
replace ? router.replace(to) : router.push(to)
|
||||||
logger.error(`Error while redirecting to ${to}`, error)
|
|
||||||
})
|
|
||||||
}, [replace, router, to])
|
}, [replace, router, to])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -3,16 +3,13 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { EditorAppBar } from '../layout/app-bar/editor-app-bar'
|
||||||
import { CommunicatorImageLightbox } from '../markdown-renderer/extensions/image/communicator-image-lightbox'
|
import { CommunicatorImageLightbox } from '../markdown-renderer/extensions/image/communicator-image-lightbox'
|
||||||
import { ExtensionEventEmitterProvider } from '../markdown-renderer/hooks/use-extension-event-emitter'
|
import { ExtensionEventEmitterProvider } from '../markdown-renderer/hooks/use-extension-event-emitter'
|
||||||
import { ChangeEditorContentContextProvider } from './change-content-context/codemirror-reference-context'
|
import { ChangeEditorContentContextProvider } from './change-content-context/codemirror-reference-context'
|
||||||
import { EditorPane } from './editor-pane/editor-pane'
|
import { EditorPane } from './editor-pane/editor-pane'
|
||||||
import { useComponentsFromAppExtensions } from './editor-pane/hooks/use-components-from-app-extensions'
|
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 { useScrollState } from './hooks/use-scroll-state'
|
||||||
import { useSetScrollSource } from './hooks/use-set-scroll-source'
|
import { useSetScrollSource } from './hooks/use-set-scroll-source'
|
||||||
import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry'
|
import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry'
|
||||||
|
@ -33,8 +30,6 @@ export enum ScrollSource {
|
||||||
export const EditorPageContent: React.FC = () => {
|
export const EditorPageContent: React.FC = () => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
|
|
||||||
useApplyDarkModeStyle()
|
|
||||||
useSaveDarkModePreferenceToLocalStorage()
|
|
||||||
useUpdateLocalHistoryEntry()
|
useUpdateLocalHistoryEntry()
|
||||||
|
|
||||||
const scrollSource = useRef<ScrollSource>(ScrollSource.EDITOR)
|
const scrollSource = useRef<ScrollSource>(ScrollSource.EDITOR)
|
||||||
|
@ -67,14 +62,13 @@ export const EditorPageContent: React.FC = () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const editorExtensionComponents = useComponentsFromAppExtensions()
|
const editorExtensionComponents = useComponentsFromAppExtensions()
|
||||||
|
useNoteAndAppTitle()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChangeEditorContentContextProvider>
|
<ChangeEditorContentContextProvider>
|
||||||
<ExtensionEventEmitterProvider>
|
<ExtensionEventEmitterProvider>
|
||||||
{editorExtensionComponents}
|
{editorExtensionComponents}
|
||||||
<CommunicatorImageLightbox />
|
<CommunicatorImageLightbox />
|
||||||
<HeadMetaProperties />
|
|
||||||
<MotdModal />
|
|
||||||
<div className={'d-flex flex-column vh-100'}>
|
<div className={'d-flex flex-column vh-100'}>
|
||||||
<EditorAppBar />
|
<EditorAppBar />
|
||||||
<div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}>
|
<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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { useNoteTitle } from '../../../../../hooks/common/use-note-title'
|
import { useNoteTitle } from '../../../../../hooks/common/use-note-title'
|
||||||
import { Logger } from '../../../../../utils/logger'
|
|
||||||
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
||||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||||
import { MessageType } from '@hedgedoc/commons'
|
import { MessageType } from '@hedgedoc/commons'
|
||||||
import type { Listener } from 'eventemitter2'
|
import type { Listener } from 'eventemitter2'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useCallback, useEffect } from 'react'
|
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.
|
* 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
|
noteTitle
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
router?.push('/history').catch((error: Error) => {
|
router.push('/history')
|
||||||
logger.error(`Error while redirecting to /history`, error)
|
|
||||||
})
|
|
||||||
}, [router, noteTitle, dispatchUiNotification])
|
}, [router, noteTitle, dispatchUiNotification])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -4,7 +4,9 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||||
|
import { store } from '../../../../../redux'
|
||||||
import { setRealtimeUsers } from '../../../../../redux/realtime/methods'
|
import { setRealtimeUsers } from '../../../../../redux/realtime/methods'
|
||||||
|
import { RealtimeStatusActionType } from '../../../../../redux/realtime/types'
|
||||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||||
import { MessageType } from '@hedgedoc/commons'
|
import { MessageType } from '@hedgedoc/commons'
|
||||||
import type { Listener } from 'eventemitter2'
|
import type { Listener } from 'eventemitter2'
|
||||||
|
@ -40,4 +42,13 @@ export const useReceiveRealtimeUsers = (messageTransporter: MessageTransporter):
|
||||||
messageTransporter.sendMessage({ type: MessageType.REALTIME_USER_STATE_REQUEST })
|
messageTransporter.sendMessage({ type: MessageType.REALTIME_USER_STATE_REQUEST })
|
||||||
}
|
}
|
||||||
}, [isConnected, messageTransporter])
|
}, [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 { useAppTitle } from '../../../hooks/common/use-app-title'
|
||||||
import { useNoteTitle } from '../../../hooks/common/use-note-title'
|
import { useNoteTitle } from '../../../hooks/common/use-note-title'
|
||||||
import { useHasMarkdownContentBeenChangedInBackground } from './hooks/use-has-markdown-content-been-changed-in-background'
|
import { useHasMarkdownContentBeenChangedInBackground } from './hooks/use-has-markdown-content-been-changed-in-background'
|
||||||
import Head from 'next/head'
|
import { useEffect, useMemo } from 'react'
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the note and app title for the browser window
|
* Sets the note and app title for the browser window
|
||||||
*/
|
*/
|
||||||
export const NoteAndAppTitleHead: React.FC = () => {
|
export const useNoteAndAppTitle = (): void => {
|
||||||
const noteTitle = useNoteTitle()
|
const noteTitle = useNoteTitle()
|
||||||
const appTitle = useAppTitle()
|
const appTitle = useAppTitle()
|
||||||
const showDot = useHasMarkdownContentBeenChangedInBackground()
|
const showDot = useHasMarkdownContentBeenChangedInBackground()
|
||||||
|
@ -21,9 +20,7 @@ export const NoteAndAppTitleHead: React.FC = () => {
|
||||||
return (showDot ? '• ' : '') + noteTitle + ' - ' + appTitle
|
return (showDot ? '• ' : '') + noteTitle + ' - ' + appTitle
|
||||||
}, [appTitle, noteTitle, showDot])
|
}, [appTitle, noteTitle, showDot])
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<Head>
|
document.title = noteAndAppTitle
|
||||||
<title>{noteAndAppTitle}</title>
|
}, [noteAndAppTitle])
|
||||||
</Head>
|
|
||||||
)
|
|
||||||
}
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* 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 { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||||
import { useBooleanState } from '../../../../../hooks/common/use-boolean-state'
|
import { useBooleanState } from '../../../../../hooks/common/use-boolean-state'
|
||||||
import { cypressId } from '../../../../../utils/cypress-attribute'
|
import { cypressId } from '../../../../../utils/cypress-attribute'
|
||||||
import { Logger } from '../../../../../utils/logger'
|
|
||||||
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
||||||
import { SidebarButton } from '../../sidebar-button/sidebar-button'
|
import { SidebarButton } from '../../sidebar-button/sidebar-button'
|
||||||
import type { SpecificSidebarEntryProps } from '../../types'
|
import type { SpecificSidebarEntryProps } from '../../types'
|
||||||
import { DeleteNoteModal } from './delete-note-modal'
|
import { DeleteNoteModal } from './delete-note-modal'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/navigation'
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import React, { Fragment, useCallback } from 'react'
|
import React, { Fragment, useCallback } from 'react'
|
||||||
import { Trash as IconTrash } from 'react-bootstrap-icons'
|
import { Trash as IconTrash } from 'react-bootstrap-icons'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const logger = new Logger('note-deletion')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar entry that can be used to delete the current note.
|
* 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(() => {
|
const deleteNoteAndCloseDialog = useCallback(() => {
|
||||||
deleteNote(noteId)
|
deleteNote(noteId)
|
||||||
.then(() => {
|
.then(() => router.push('/history'))
|
||||||
router.push('/history').catch((reason) => logger.error('Error while redirecting to /history', reason))
|
|
||||||
})
|
|
||||||
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
|
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
|
||||||
.finally(closeModal)
|
.finally(closeModal)
|
||||||
}, [closeModal, noteId, router, showErrorNotification])
|
}, [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)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
'use client'
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
|
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
|
||||||
import { useOutlineButtonVariant } from '../../../hooks/dark-mode/use-outline-button-variant'
|
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 { cypressId } from '../../../utils/cypress-attribute'
|
||||||
import { IconButton } from '../../common/icon-button/icon-button'
|
import { IconButton } from '../../common/icon-button/icon-button'
|
||||||
import { SettingsModal } from './settings-modal'
|
import { SettingsModal } from './settings-modal'
|
||||||
|
@ -19,6 +20,7 @@ export type SettingsButtonProps = Omit<ButtonProps, 'onClick'>
|
||||||
export const SettingsButton: React.FC<SettingsButtonProps> = (props) => {
|
export const SettingsButton: React.FC<SettingsButtonProps> = (props) => {
|
||||||
const [show, showModal, hideModal] = useBooleanState(false)
|
const [show, showModal, hideModal] = useBooleanState(false)
|
||||||
const buttonVariant = useOutlineButtonVariant()
|
const buttonVariant = useOutlineButtonVariant()
|
||||||
|
useSaveDarkModePreferenceToLocalStorage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
|
|
@ -20,13 +20,13 @@ export const historyToolbarStateContext = createContext<HistoryToolbarStateWithD
|
||||||
* @param children The children that should receive the toolbar state via context.
|
* @param children The children that should receive the toolbar state via context.
|
||||||
*/
|
*/
|
||||||
export const HistoryToolbarStateContextProvider: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
|
export const HistoryToolbarStateContextProvider: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||||
const urlParameterSearch = useSingleStringUrlParameter('search', '')
|
const search = useSingleStringUrlParameter('search', '')
|
||||||
const urlParameterSelectedTags = useArrayStringUrlParameter('selectedTags')
|
const selectedTags = useArrayStringUrlParameter('selectedTags')
|
||||||
|
|
||||||
const stateWithDispatcher = useState<HistoryToolbarState>(() => ({
|
const stateWithDispatcher = useState<HistoryToolbarState>(() => ({
|
||||||
viewState: ViewStateEnum.CARD,
|
viewState: ViewStateEnum.CARD,
|
||||||
search: urlParameterSearch,
|
search: search,
|
||||||
selectedTags: urlParameterSelectedTags,
|
selectedTags: selectedTags,
|
||||||
titleSortDirection: SortModeEnum.no,
|
titleSortDirection: SortModeEnum.no,
|
||||||
lastVisitedSortDirection: SortModeEnum.down
|
lastVisitedSortDirection: SortModeEnum.down
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -3,42 +3,46 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { useHistoryToolbarState } from './use-history-toolbar-state'
|
||||||
import equal from 'fast-deep-equal'
|
import equal from 'fast-deep-equal'
|
||||||
import { useRouter } from 'next/router'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
const logger = new Logger('useSyncToolbarStateToUrl')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pushes the current search and tag selection into the navigation history stack of the browser.
|
* Pushes the current search and tag selection into the navigation history stack of the browser.
|
||||||
*/
|
*/
|
||||||
export const useSyncToolbarStateToUrlEffect = (): void => {
|
export const useSyncToolbarStateToUrlEffect = (): void => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const urlParameterSearch = useSingleStringUrlParameter('search', '')
|
const searchParams = useSearchParams()
|
||||||
const urlParameterSelectedTags = useArrayStringUrlParameter('selectedTags')
|
|
||||||
const [state] = useHistoryToolbarState()
|
const [state] = useHistoryToolbarState()
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!equal(state.search, urlParameterSearch) || !equal(state.selectedTags, urlParameterSelectedTags)) {
|
if (!searchParams || !pathname) {
|
||||||
router
|
return
|
||||||
.push(
|
|
||||||
{
|
|
||||||
pathname: router.pathname,
|
|
||||||
query: {
|
|
||||||
search: state.search === '' ? [] : state.search,
|
|
||||||
selectedTags: state.selectedTags
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
undefined,
|
const urlParameterSearch = searchParams.get('search') ?? ''
|
||||||
{
|
const urlParameterSelectedTags = searchParams.getAll('selectedTags')
|
||||||
shallow: true
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
let shouldUpdate = false
|
||||||
|
|
||||||
|
if (!equal(state.search, urlParameterSearch)) {
|
||||||
|
if (!state.search) {
|
||||||
|
params.delete('search')
|
||||||
|
} else {
|
||||||
|
params.set('search', state.search)
|
||||||
}
|
}
|
||||||
)
|
shouldUpdate = true
|
||||||
.catch(() => logger.error("Can't update route"))
|
|
||||||
}
|
}
|
||||||
}, [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
|
* 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 { BaseAppBar } from '../layout/app-bar/base-app-bar'
|
||||||
import { HeaderBar } from './navigation/header-bar/header-bar'
|
import { HeaderBar } from './navigation/header-bar/header-bar'
|
||||||
import type { PropsWithChildren } from 'react'
|
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.
|
* @param children The children that should be rendered on the page.
|
||||||
*/
|
*/
|
||||||
export const LandingLayout: React.FC<PropsWithChildren> = ({ children }) => {
|
export const LandingLayout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
useApplyDarkModeStyle()
|
|
||||||
useSaveDarkModePreferenceToLocalStorage()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BaseAppBar />
|
<BaseAppBar />
|
||||||
<MotdModal />
|
|
||||||
<Container className='d-flex flex-column'>
|
<Container className='d-flex flex-column'>
|
||||||
<HeaderBar />
|
<HeaderBar />
|
||||||
<div className={'d-flex flex-column justify-content-between flex-fill text-center'}>
|
<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 { cypressId } from '../../../../utils/cypress-attribute'
|
||||||
import styles from './header-nav-link.module.scss'
|
import styles from './header-nav-link.module.scss'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { usePathname } from 'next/navigation'
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { Nav } from 'react-bootstrap'
|
import { Nav } from 'react-bootstrap'
|
||||||
|
@ -25,17 +25,17 @@ export interface HeaderNavLinkProps extends PropsWithDataCypressId {
|
||||||
* @param props Other navigation item props
|
* @param props Other navigation item props
|
||||||
*/
|
*/
|
||||||
export const HeaderNavLink: React.FC<PropsWithChildren<HeaderNavLinkProps>> = ({ to, children, ...props }) => {
|
export const HeaderNavLink: React.FC<PropsWithChildren<HeaderNavLinkProps>> = ({ to, children, ...props }) => {
|
||||||
const { route } = useRouter()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const className = useMemo(() => {
|
const className = useMemo(() => {
|
||||||
return concatCssClasses(
|
return concatCssClasses(
|
||||||
{
|
{
|
||||||
[styles.active]: route === to
|
[styles.active]: pathname === to
|
||||||
},
|
},
|
||||||
'nav-link',
|
'nav-link',
|
||||||
styles.link
|
styles.link
|
||||||
)
|
)
|
||||||
}, [route, to])
|
}, [pathname, to])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { clearUser } from '../../../redux/user/methods'
|
||||||
import { cypressId } from '../../../utils/cypress-attribute'
|
import { cypressId } from '../../../utils/cypress-attribute'
|
||||||
import { UiIcon } from '../../common/icons/ui-icon'
|
import { UiIcon } from '../../common/icons/ui-icon'
|
||||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/navigation'
|
||||||
import React, { useCallback } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { Dropdown } from 'react-bootstrap'
|
import { Dropdown } from 'react-bootstrap'
|
||||||
import { BoxArrowRight as IconBoxArrowRight } from 'react-bootstrap-icons'
|
import { BoxArrowRight as IconBoxArrowRight } from 'react-bootstrap-icons'
|
||||||
|
|
|
@ -3,24 +3,23 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { ShowIf } from '../../../../../common/show-if/show-if'
|
||||||
import { DropdownHeader } from '../dropdown-header'
|
import { DropdownHeader } from '../dropdown-header'
|
||||||
import { TranslatedDropdownItem } from '../translated-dropdown-item'
|
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 { Dropdown } from 'react-bootstrap'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useFrontendConfig } from '../../../../../common/frontend-config-context/use-frontend-config'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the legal submenu for the help dropdown.
|
* Renders the legal submenu for the help dropdown.
|
||||||
*/
|
*/
|
||||||
export const LegalSubmenu: React.FC = () => {
|
export const LegalSubmenu: React.FC = (): null | ReactElement => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
const specialUrls = useFrontendConfig().specialUrls
|
const specialUrls = useFrontendConfig().specialUrls
|
||||||
const linksConfigured = useMemo(
|
|
||||||
() => specialUrls.privacy || specialUrls.termsOfUse || specialUrls.imprint,
|
const linksConfigured = specialUrls?.privacy || specialUrls?.termsOfUse || specialUrls?.imprint
|
||||||
[specialUrls]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!linksConfigured) {
|
if (!linksConfigured) {
|
||||||
return null
|
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
|
* 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'
|
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 => {
|
const getBackendAuthUrl = (providerIdentifer: string): string => {
|
||||||
return `auth/${providerIdentifer}`
|
return `/auth/${providerIdentifer}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = new Logger('GetOneClickProviderMetadata')
|
const logger = new Logger('GetOneClickProviderMetadata')
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
'use client'
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
|
|
|
@ -3,9 +3,9 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { cypressId } from '../../../../utils/cypress-attribute'
|
||||||
import type { ScrollProps } from '../../../editor-page/synced-scroll/scroll-props'
|
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 type { LineMarkers } from '../../../markdown-renderer/extensions/linemarker/add-line-marker-markdown-it-plugin'
|
||||||
import { LinemarkerMarkdownExtension } from '../../../markdown-renderer/extensions/linemarker/linemarker-markdown-extension'
|
import { LinemarkerMarkdownExtension } from '../../../markdown-renderer/extensions/linemarker/linemarker-markdown-extension'
|
||||||
import { useCalculateLineMarkerPosition } from '../../../markdown-renderer/hooks/use-calculate-line-marker-positions'
|
import { useCalculateLineMarkerPosition } from '../../../markdown-renderer/hooks/use-calculate-line-marker-positions'
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { 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 { useMarkdownExtensions } from '../../../markdown-renderer/hooks/use-markdown-extensions'
|
||||||
import { MarkdownToReact } from '../../../markdown-renderer/markdown-to-react/markdown-to-react'
|
import { MarkdownToReact } from '../../../markdown-renderer/markdown-to-react/markdown-to-react'
|
||||||
import { useOnHeightChange } from '../../hooks/use-on-height-change'
|
import { useOnHeightChange } from '../../hooks/use-on-height-change'
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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'
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
|
||||||
export enum HttpMethod {
|
export enum HttpMethod {
|
||||||
|
@ -22,6 +22,7 @@ export enum HttpMethod {
|
||||||
* @param res The response object.
|
* @param res The response object.
|
||||||
* @param response The response data that will be returned when the HTTP method was the expected one.
|
* @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 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.
|
* @return {@link true} if the HTTP method of the request is the expected one, {@link false} otherwise.
|
||||||
*/
|
*/
|
||||||
export const respondToMatchingRequest = <T>(
|
export const respondToMatchingRequest = <T>(
|
||||||
|
@ -29,17 +30,42 @@ export const respondToMatchingRequest = <T>(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
response: T,
|
response: T,
|
||||||
statusCode = 200
|
statusCode = 200,
|
||||||
|
respondMethodNotAllowedOnMismatch = true
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!isMockMode) {
|
if (!isMockMode) {
|
||||||
res.status(404).send('Mock API is disabled')
|
res.status(404).send('Mock API is disabled')
|
||||||
return false
|
return false
|
||||||
}
|
} else if (method === req.method) {
|
||||||
if (method !== req.method) {
|
|
||||||
res.status(405).send('Method not allowed')
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
res.status(statusCode).json(response)
|
res.status(statusCode).json(response)
|
||||||
return true
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { useRouter } from 'next/router'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -13,10 +13,9 @@ import { useMemo } from 'react'
|
||||||
* @return An array of values extracted from the router.
|
* @return An array of values extracted from the router.
|
||||||
*/
|
*/
|
||||||
export const useArrayStringUrlParameter = (parameter: string): string[] => {
|
export const useArrayStringUrlParameter = (parameter: string): string[] => {
|
||||||
const router = useRouter()
|
const router = useSearchParams()
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const value = router.query[parameter]
|
return router?.getAll(parameter) ?? []
|
||||||
return (typeof value === 'string' ? [value] : value) ?? []
|
}, [parameter, router])
|
||||||
}, [parameter, router.query])
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { baseUrlContext } from '../../components/common/base-url/base-url-context-provider'
|
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'
|
import { useContext, useMemo } from 'react'
|
||||||
|
|
||||||
export enum ORIGIN {
|
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?')
|
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 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.renderer
|
||||||
: baseUrls.editor
|
: 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { useRouter } from 'next/router'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -14,10 +14,9 @@ import { useMemo } from 'react'
|
||||||
* @return A value extracted from the router.
|
* @return A value extracted from the router.
|
||||||
*/
|
*/
|
||||||
export const useSingleStringUrlParameter = <T>(parameter: string, fallback: T): string | T => {
|
export const useSingleStringUrlParameter = <T>(parameter: string, fallback: T): string | T => {
|
||||||
const router = useRouter()
|
const router = useSearchParams()
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const value = router.query[parameter]
|
return router?.get(parameter) ?? fallback
|
||||||
return (typeof value === 'string' ? value : value?.[0]) ?? fallback
|
}, [fallback, parameter, router])
|
||||||
}, [fallback, parameter, router.query])
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 type { FrontendConfig } from '../../../api/config/types'
|
||||||
import { AuthProviderType } 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'
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
|
||||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
const initialConfig: FrontendConfig = {
|
||||||
respondToMatchingRequest<FrontendConfig>(HttpMethod.GET, req, res, {
|
|
||||||
allowAnonymous: true,
|
allowAnonymous: true,
|
||||||
allowRegister: 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: {
|
branding: {
|
||||||
name: 'DEMO Corp',
|
name: 'DEMO Corp',
|
||||||
logo: '/public/img/demo.png'
|
logo: '/public/img/demo.png'
|
||||||
|
@ -63,13 +27,66 @@ const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
imprint: 'https://example.com/imprint'
|
imprint: 'https://example.com/imprint'
|
||||||
},
|
},
|
||||||
version: {
|
version: {
|
||||||
major: 2,
|
major: isTestMode ? 0 : 2,
|
||||||
minor: 0,
|
minor: 0,
|
||||||
patch: 0,
|
patch: 0,
|
||||||
|
preRelease: isTestMode ? undefined : '',
|
||||||
commit: 'mock'
|
commit: 'mock'
|
||||||
},
|
},
|
||||||
plantumlServer: 'https://www.plantuml.com/plantuml',
|
plantumlServer: isTestMode ? 'http://mock-plantuml.local' : 'https://www.plantuml.com/plantuml',
|
||||||
maxDocumentLength: 1000000
|
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)
|
* 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', () => {
|
it('should return the base urls if both are valid urls', () => {
|
||||||
process.env.HD_BASE_URL = 'https://editor.example.org/'
|
process.env.HD_BASE_URL = 'https://editor.example.org/'
|
||||||
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/'
|
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/'
|
||||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
const sut = new BaseUrlFromEnvExtractor()
|
||||||
const result = baseUrlFromEnvExtractor.extractBaseUrls()
|
|
||||||
expect(result.isPresent()).toBeTruthy()
|
expect(sut.extractBaseUrls()).toStrictEqual({
|
||||||
expect(result.get()).toStrictEqual({
|
|
||||||
renderer: 'https://renderer.example.org/',
|
renderer: 'https://renderer.example.org/',
|
||||||
editor: 'https://editor.example.org/'
|
editor: 'https://editor.example.org/'
|
||||||
})
|
})
|
||||||
|
@ -21,31 +20,33 @@ describe('BaseUrlFromEnvExtractor', () => {
|
||||||
it('should return an empty optional if no var is set', () => {
|
it('should return an empty optional if no var is set', () => {
|
||||||
process.env.HD_BASE_URL = undefined
|
process.env.HD_BASE_URL = undefined
|
||||||
process.env.HD_RENDERER_BASE_URL = undefined
|
process.env.HD_RENDERER_BASE_URL = undefined
|
||||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
const sut = new BaseUrlFromEnvExtractor()
|
||||||
expect(baseUrlFromEnvExtractor.extractBaseUrls().isEmpty()).toBeTruthy()
|
|
||||||
|
expect(() => sut.extractBaseUrls()).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return an empty optional if editor base url isn't an URL", () => {
|
it("should return an empty optional if editor base url isn't an URL", () => {
|
||||||
process.env.HD_BASE_URL = 'bibedibabedibu'
|
process.env.HD_BASE_URL = 'bibedibabedibu'
|
||||||
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/'
|
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/'
|
||||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
const sut = new BaseUrlFromEnvExtractor()
|
||||||
expect(baseUrlFromEnvExtractor.extractBaseUrls().isEmpty()).toBeTruthy()
|
|
||||||
|
expect(() => sut.extractBaseUrls()).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return an empty optional if renderer base url isn't an URL", () => {
|
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_BASE_URL = 'https://editor.example.org/'
|
||||||
process.env.HD_RENDERER_BASE_URL = 'bibedibabedibu'
|
process.env.HD_RENDERER_BASE_URL = 'bibedibabedibu'
|
||||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
const sut = new BaseUrlFromEnvExtractor()
|
||||||
expect(baseUrlFromEnvExtractor.extractBaseUrls().isEmpty()).toBeTruthy()
|
|
||||||
|
expect(() => sut.extractBaseUrls()).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return an optional if editor base url isn't ending with a slash", () => {
|
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_BASE_URL = 'https://editor.example.org'
|
||||||
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/'
|
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/'
|
||||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
const sut = new BaseUrlFromEnvExtractor()
|
||||||
const result = baseUrlFromEnvExtractor.extractBaseUrls()
|
|
||||||
expect(result.isPresent()).toBeTruthy()
|
expect(sut.extractBaseUrls()).toStrictEqual({
|
||||||
expect(result.get()).toStrictEqual({
|
|
||||||
renderer: 'https://renderer.example.org/',
|
renderer: 'https://renderer.example.org/',
|
||||||
editor: 'https://editor.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", () => {
|
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_BASE_URL = 'https://editor.example.org/'
|
||||||
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org'
|
process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org'
|
||||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
const sut = new BaseUrlFromEnvExtractor()
|
||||||
const result = baseUrlFromEnvExtractor.extractBaseUrls()
|
|
||||||
expect(result.isPresent()).toBeTruthy()
|
expect(sut.extractBaseUrls()).toStrictEqual({
|
||||||
expect(result.get()).toStrictEqual({
|
|
||||||
renderer: 'https://renderer.example.org/',
|
renderer: 'https://renderer.example.org/',
|
||||||
editor: 'https://editor.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', () => {
|
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/'
|
process.env.HD_BASE_URL = 'https://editor.example.org/'
|
||||||
delete process.env.HD_RENDERER_BASE_URL
|
delete process.env.HD_RENDERER_BASE_URL
|
||||||
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
|
const sut = new BaseUrlFromEnvExtractor()
|
||||||
const result = baseUrlFromEnvExtractor.extractBaseUrls()
|
|
||||||
expect(result.isPresent()).toBeTruthy()
|
expect(sut.extractBaseUrls()).toStrictEqual({
|
||||||
expect(result.get()).toStrictEqual({
|
|
||||||
renderer: 'https://editor.example.org/',
|
renderer: 'https://editor.example.org/',
|
||||||
editor: '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 type { BaseUrls } from '../components/common/base-url/base-url-context-provider'
|
||||||
import { Logger } from './logger'
|
import { Logger } from './logger'
|
||||||
import { isTestMode } from './test-modes'
|
import { isTestMode, isBuildTime } from './test-modes'
|
||||||
import { NoSubdirectoryAllowedError, parseUrl } from '@hedgedoc/commons'
|
import { NoSubdirectoryAllowedError, parseUrl } from '@hedgedoc/commons'
|
||||||
import { Optional } from '@mrdrogdrog/optional'
|
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 {
|
export class BaseUrlFromEnvExtractor {
|
||||||
private baseUrls: Optional<BaseUrls> | undefined
|
private baseUrls: BaseUrls | undefined
|
||||||
private readonly logger = new Logger('Base URL Configuration')
|
private readonly logger = new Logger('Base URL Configuration')
|
||||||
|
|
||||||
private extractUrlFromEnvVar(envVarName: string, envVarValue: string | undefined): Optional<URL> {
|
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)
|
return this.extractUrlFromEnvVar('HD_RENDERER_BASE_URL', process.env.HD_RENDERER_BASE_URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
private renewBaseUrls(): void {
|
private renewBaseUrls(): BaseUrls {
|
||||||
this.baseUrls = this.extractEditorBaseUrlFromEnv().flatMap((editorBaseUrl) =>
|
return this.extractEditorBaseUrlFromEnv()
|
||||||
|
.flatMap((editorBaseUrl) =>
|
||||||
this.extractRendererBaseUrlFromEnv(editorBaseUrl).map((rendererBaseUrl) => {
|
this.extractRendererBaseUrlFromEnv(editorBaseUrl).map((rendererBaseUrl) => {
|
||||||
return {
|
return {
|
||||||
editor: editorBaseUrl.toString(),
|
editor: editorBaseUrl.toString(),
|
||||||
|
@ -60,14 +61,7 @@ export class BaseUrlFromEnvExtractor {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
this.baseUrls.ifPresent((urls) => {
|
.orElseThrow(() => new Error('couldnt parse env vars'))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -75,10 +69,28 @@ export class BaseUrlFromEnvExtractor {
|
||||||
*
|
*
|
||||||
* @return An {@link Optional} with the base urls.
|
* @return An {@link Optional} with the base urls.
|
||||||
*/
|
*/
|
||||||
public extractBaseUrls(): Optional<BaseUrls> {
|
public extractBaseUrls(): BaseUrls {
|
||||||
if (!this.isEnvironmentExtractDone()) {
|
if (isBuildTime) {
|
||||||
this.renewBaseUrls()
|
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|