feat(explore): add common explore page layout

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-01-14 21:43:15 +01:00
parent 055d2b6c5b
commit 869366a744
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
24 changed files with 525 additions and 18 deletions

View file

@ -3,13 +3,26 @@
"slogan": "Ideas grow better together",
"icon": "HedgeDoc logo with text"
},
"notificationTest": {
"title": "Test",
"content": "It works!"
},
"navigation": {
"newNote": "New Note"
},
"explore": {
"title": "Explore notes",
"welcome": {
"user": "Welcome, {{userName}}!",
"guest": "Welcome, guest!"
},
"pinnedNotes": {
"title": "Pinned notes",
"unpin": "Click to unpin this note",
"empty": "You don't have any pinned notes yet."
},
"modes": {
"my": "My notes",
"shared": "Shared with me",
"public": "Public notes"
}
},
"renderer": {
"highlightCode": {
"copyCode": "Copy code to clipboard"

View file

@ -0,0 +1,18 @@
'use client'
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { BaseAppBar } from '../../../../../components/layout/app-bar/base-app-bar'
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
export default function AppBar() {
useTranslation()
return (
<BaseAppBar>
<Trans i18nKey={'explore.modes.my'} />
</BaseAppBar>
)
}

View file

@ -0,0 +1,18 @@
'use client'
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { BaseAppBar } from '../../../../../components/layout/app-bar/base-app-bar'
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
export default function AppBar() {
useTranslation()
return (
<BaseAppBar>
<Trans i18nKey={'explore.modes.public'} />
</BaseAppBar>
)
}

View file

@ -0,0 +1,18 @@
'use client'
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { BaseAppBar } from '../../../../../components/layout/app-bar/base-app-bar'
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
export default function AppBar() {
useTranslation()
return (
<BaseAppBar>
<Trans i18nKey={'explore.modes.shared'} />
</BaseAppBar>
)
}

View file

@ -0,0 +1,31 @@
'use client'
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React from 'react'
import { Container } from 'react-bootstrap'
import { Welcome } from '../../../components/explore-page/welcome'
import { ModeSelection } from '../../../components/explore-page/mode-selection/mode-selection'
import { PinnedNotes } from '../../../components/explore-page/pinned-notes/pinned-notes'
/**
* Layout for the login page with the intro content on the left and children on the right.
* @param children The content to show on the right
*/
export type ExploreLayoutProps = PropsWithChildren
export default function ExploreLayout({ children }: ExploreLayoutProps) {
return (
<Container>
<Welcome />
<PinnedNotes />
<ModeSelection />
{children}
</Container>
)
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NextPage } from 'next'
import { LandingLayout } from '../../../../components/landing-layout/landing-layout'
const ExploreMyNotesPage: NextPage = () => {
return <LandingLayout>Own Notes</LandingLayout>
}
export default ExploreMyNotesPage

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NextPage } from 'next'
import { LandingLayout } from '../../../../components/landing-layout/landing-layout'
const ExplorePublicPage: NextPage = () => {
return <LandingLayout>Public Notes</LandingLayout>
}
export default ExplorePublicPage

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NextPage } from 'next'
import { LandingLayout } from '../../../../components/landing-layout/landing-layout'
const ExploreSharedPage: NextPage = () => {
return <LandingLayout>Shared Notes</LandingLayout>
}
export default ExploreSharedPage

View file

@ -8,7 +8,7 @@
import type { NextPage } from 'next'
import React from 'react'
import { RedirectToParamOrHistory } from '../../../components/login-page/redirect-to-param-or-history'
import { RedirectToParamOrExplore } from '../../../components/login-page/redirect-to-param-or-explore'
import { LocalLoginCard } from '../../../components/login-page/local-login/local-login-card'
import { LdapLoginCards } from '../../../components/login-page/ldap/ldap-login-cards'
import { OneClickLoginCard } from '../../../components/login-page/one-click/one-click-login-card'
@ -20,7 +20,7 @@ const LoginPage: NextPage = () => {
const userLoggedIn = useIsLoggedIn()
if (userLoggedIn) {
return <RedirectToParamOrHistory />
return <RedirectToParamOrExplore />
}
return (

View file

@ -8,7 +8,7 @@ import type { NextPage } from 'next'
import { useTranslation } from 'react-i18next'
import React from 'react'
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
import { RedirectToParamOrHistory } from '../../../components/login-page/redirect-to-param-or-history'
import { RedirectToParamOrExplore } from '../../../components/login-page/redirect-to-param-or-explore'
import { NewUserCard } from '../../../components/login-page/new-user/new-user-card'
import { LoginLayout } from '../../../components/layout/login-layout'
@ -20,7 +20,7 @@ const NewUserPage: NextPage = () => {
const userLoggedIn = useIsLoggedIn()
if (userLoggedIn) {
return <RedirectToParamOrHistory />
return <RedirectToParamOrExplore />
}
return (

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import {
FileEarmarkSlidesFill as IconFileEarmarkSlidesFill,
FileEarmarkTextFill as IconFileEarmarkTextFill
} from 'react-bootstrap-icons'
import { NoteType } from '@hedgedoc/commons'
import { UiIcon } from '../icons/ui-icon'
import type { UiIconProps } from '../icons/ui-icon'
interface NoteTypeIconProps extends Omit<UiIconProps, 'icon'> {
noteType: NoteType
}
export const NoteTypeIcon: React.FC<NoteTypeIconProps> = ({ noteType, ...props }) => {
const icon = useMemo(() => {
switch (noteType) {
case NoteType.DOCUMENT:
default:
return IconFileEarmarkTextFill
case NoteType.SLIDE:
return IconFileEarmarkSlidesFill
}
}, [noteType])
return <UiIcon icon={icon} {...props} />
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.link {
color: var(--bs-secondary-color);
text-decoration: underline;
cursor: pointer;
margin-right: 1rem;
}
.active {
color: var(--bs-heading-color);
text-decoration: none;
font-weight: bold;
cursor: text;
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Trans, useTranslation } from 'react-i18next'
import styles from './mode-link.module.css'
import type { Mode } from './mode'
export interface ModeLinkProps {
mode: Mode
}
/**
* Renders a link to switch to another mode of the explore page
* @param mode The target mode to link to
*/
export const ModeLink: React.FC<ModeLinkProps> = ({ mode }) => {
useTranslation()
const path = usePathname()
const isActive = useMemo(() => path === `/explore/${mode}`, [path, mode])
return isActive ? (
<span className={`${styles.link} ${styles.active}`}>
<Trans i18nKey={`explore.modes.${mode}`} />
</span>
) : (
<Link href={`/explore/${mode}`} className={styles.link}>
<Trans i18nKey={`explore.modes.${mode}`} />
</Link>
)
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { ModeLink } from './mode-link'
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
import { Mode } from './mode'
/**
* Renders the switcher for the different modes of the explore page.
* Since notes can't be shared with anonymous guests, the shared mode is only shown to logged-in users.
*/
export const ModeSelection: React.FC = () => {
const userLoggedIn = useIsLoggedIn()
return (
<h2 className={'mb-3'}>
<ModeLink mode={Mode.MY_NOTES} />
{userLoggedIn && <ModeLink mode={Mode.SHARED_WITH_ME} />}
<ModeLink mode={Mode.PUBLIC} />
</h2>
)
}

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum Mode {
MY_NOTES = 'my',
SHARED_WITH_ME = 'shared',
PUBLIC = 'public'
}

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.card {
min-width: 25rem;
height: 8rem;
position: relative;
text-decoration: none;
}
.card:hover {
background-color: var(--bs-card-border-color);
}
.bookmark {
position: absolute;
top: -2px;
right: 1rem;
padding: 0;
color: #c40c0c;
z-index: 2;
}
.star {
position: absolute;
top: 0;
right: 1.5rem;
padding: 0;
width: 11px;
height: 16px;
background-color: #f8df33;
z-index: 1;
}
.title {
width: 22rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.titleText {
padding-top: 2rem;
padding-left: 0.5rem;
}

View file

@ -0,0 +1,82 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { type MouseEvent, useMemo } from 'react'
import { Badge, Card } from 'react-bootstrap'
import { DateTime } from 'luxon'
import { BookmarkStarFill as IconPinned } from 'react-bootstrap-icons'
import styles from './pinned-note-card.module.css'
import { useCallback } from 'react'
import { NoteTypeIcon } from '../../common/note-type-icon/note-type-icon'
import type { NoteType } from '@hedgedoc/commons'
import { UiIcon } from '../../common/icons/ui-icon'
import Link from 'next/link'
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import { useRouter } from 'next/navigation'
export interface NoteCardProps {
title: string
id: string
type: NoteType
lastVisited: string
created: string
pinned: boolean
tags: string[]
primaryAddress: string
}
export const PinnedNoteCard: React.FC<NoteCardProps> = ({ title, id, lastVisited, type, primaryAddress, tags }) => {
const router = useRouter()
const labelTag = useTranslatedText('explore.filters.byTag')
const labelUnpinNote = useTranslatedText('explore.pinnedNotes.unpin')
const lastVisitedString = useMemo(() => DateTime.fromISO(lastVisited).toRelative(), [lastVisited])
// const createdString = DateTime.fromISO(created).toFormat('DDDD T')
const onClickUnpin = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
event.preventDefault()
alert(`UnFav ${id}`)
},
[id]
)
const onClickTag = useCallback(
(tag: string) => {
return (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault()
router.push(`?search=tag:${tag}`)
}
},
[router]
)
const tagsChips = useMemo(() => {
return tags.map((tag) => (
<Badge key={tag} bg={'secondary'} pill={true} className={'me-1'} onClick={onClickTag(tag)} title={labelTag}>
{tag}
</Badge>
))
}, [tags, onClickTag, labelTag])
return (
<li className={'d-block'}>
<Card className={`${styles.card}`} as={Link} href={`/n/${primaryAddress}`}>
<Card.Body>
<div onClick={onClickUnpin} title={labelUnpinNote}>
<UiIcon icon={IconPinned} size={1.5} className={`${styles.bookmark}`} />
<div className={`${styles.star}`} />
</div>
<Card.Title className={`${styles.title}`}>
<NoteTypeIcon noteType={type} />
<span className={`${styles.titleText}`} title={title}>
{title}
</span>
</Card.Title>
<Card.Subtitle className='mb-2 text-muted'>{lastVisitedString}</Card.Subtitle>
{tagsChips}
</Card.Body>
</Card>
</li>
)
}

View file

@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useMemo } from 'react'
import type { NoteCardProps } from './pinned-note-card'
import { PinnedNoteCard } from './pinned-note-card'
import { Trans, useTranslation } from 'react-i18next'
import { NoteType } from '@hedgedoc/commons'
const mockListPinnedNotes: NoteCardProps[] = [
{
id: '1',
title: 'othermo Backend / Fullstack Dev',
type: NoteType.DOCUMENT,
pinned: true,
lastVisited: new Date(2025, 0, 2, 14, 0, 0).toISOString(),
created: new Date(2025, 0, 1, 12, 0, 0).toISOString(),
tags: ['Arbeit', 'Ausschreibung'],
primaryAddress: 'othermo'
},
{
id: '2',
title: 'HedgeDoc e.V.',
type: NoteType.DOCUMENT,
pinned: false,
lastVisited: new Date(2025, 0, 13, 14, 0, 0).toISOString(),
created: new Date(2025, 0, 12, 12, 0, 0).toISOString(),
tags: ['HedgeDoc', 'Verein'],
primaryAddress: 'ev'
},
{
id: '3',
title: 'Sister projects of HedgeDoc for the future',
type: NoteType.DOCUMENT,
pinned: false,
lastVisited: new Date(2025, 0, 13, 14, 0, 0).toISOString(),
created: new Date(2025, 0, 12, 12, 0, 0).toISOString(),
tags: ['HedgeDoc', 'Funny'],
primaryAddress: 'sister-projects'
},
{
id: '4',
title: 'HedgeDoc Keynote',
type: NoteType.SLIDE,
pinned: false,
lastVisited: new Date(2025, 0, 13, 14, 0, 0).toISOString(),
created: new Date(2025, 0, 12, 12, 0, 0).toISOString(),
tags: [],
primaryAddress: 'keynote'
},
{
id: '5',
title: 'KIF-Admin KIF 47,5',
type: NoteType.DOCUMENT,
pinned: false,
lastVisited: new Date(2020, 2, 13, 14, 0, 0).toISOString(),
created: new Date(2019, 10, 12, 12, 0, 0).toISOString(),
tags: ['KIF-Admin', 'KIF 47,5'],
primaryAddress: '5'
},
{
id: '6',
title: 'kif.rocks vs WifiOnICE/Bahn WLAN',
type: NoteType.DOCUMENT,
pinned: false,
lastVisited: new Date(2020, 0, 13, 14, 0, 0).toISOString(),
created: new Date(2020, 0, 12, 12, 0, 0).toISOString(),
tags: ['Privat', 'Blogpost'],
primaryAddress: 'wifionice'
}
]
export const PinnedNotes: React.FC = () => {
useTranslation()
const cards = useMemo(() => {
return mockListPinnedNotes.map((note: NoteCardProps) => <PinnedNoteCard key={note.id} {...note} />)
}, [])
return (
<Fragment>
<h2 className={'mb-2'}>
<Trans i18nKey={'explore.pinnedNotes.title'} />
</h2>
<ul className={'d-block mx-2 py-2 mb-5 d-flex gap-2 w-100 overflow-x-auto'}>{cards}</ul>
</Fragment>
)
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { useApplicationState } from '../../hooks/common/use-application-state'
import { Trans, useTranslation } from 'react-i18next'
/**
* Renders the welcome message for the explore page.
*/
export const Welcome: React.FC = () => {
useTranslation()
const userName = useApplicationState((state) => state.user?.displayName)
return (
<h1 className={'my-4'}>
{userName !== undefined ? (
<Trans i18nKey={'explore.welcome.user'} values={{ userName }} />
) : (
<Trans i18nKey={'explore.welcome.guest'} />
)}
</h1>
)
}

View file

@ -20,7 +20,7 @@ export const BrandingElement: React.FC = () => {
return (
<Navbar.Brand>
<Link href='/' className='text-secondary text-decoration-none d-flex align-items-center'>
<Link href='/explore/my' className='text-secondary text-decoration-none d-flex align-items-center'>
<HedgeDocLogoHorizontalGrey
size={LogoSize.SMALL}
className={'w-auto'}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -10,15 +10,15 @@ import { Trans, useTranslation } from 'react-i18next'
import Link from 'next/link'
/**
* A button that links to the history page.
* A button that links to the public explore page.
*/
export const HistoryButton: React.FC = () => {
export const ExploreButton: React.FC = () => {
useTranslation()
return (
<Link href={'/history'}>
<Link href={'/explore/public'}>
<Button variant={'secondary'} size={'sm'}>
<Trans i18nKey='landing.navigation.history' />
<Trans i18nKey='explore.modes.public' />
</Button>
</Link>
)

View file

@ -7,7 +7,7 @@
import React from 'react'
import { Card } from 'react-bootstrap'
import { NewNoteButton } from '../../common/new-note-button/new-note-button'
import { HistoryButton } from '../../layout/app-bar/app-bar-elements/help-dropdown/history-button'
import { ExploreButton } from './explore-button'
import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config'
import { Trans, useTranslation } from 'react-i18next'
import { GuestAccessLevel } from '../../../api/config/types'
@ -32,7 +32,7 @@ export const GuestCard: React.FC = () => {
</Card.Title>
<div className={'d-flex flex-row gap-2'}>
<NewNoteButton />
<HistoryButton />
<ExploreButton />
</div>
{guestAccessLevel !== GuestAccessLevel.CREATE && (
<div className={'text-muted mt-2 small'}>

View file

@ -12,7 +12,7 @@ import { useGetPostLoginRedirectUrl } from './utils/use-get-post-login-redirect-
* Redirects the browser to the relative URL that is provided via "redirectBackTo" URL parameter.
* If no parameter has been provided or if the URL is not relative, then "/history" will be used.
*/
export const RedirectToParamOrHistory: React.FC = () => {
export const RedirectToParamOrExplore: React.FC = () => {
const redirectUrl = useGetPostLoginRedirectUrl()
return <Redirect to={redirectUrl} replace={true} />
}

View file

@ -5,7 +5,7 @@
*/
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
const defaultFallback = '/history'
const defaultFallback = '/explore/my'
/**
* Returns the URL that the user should be redirected to after logging in.