feat(explore): add filters for explore page note list

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-01-30 01:53:33 +01:00
parent 869366a744
commit 0a9b965b72
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
7 changed files with 349 additions and 2 deletions

View file

@ -21,6 +21,23 @@
"my": "My notes", "my": "My notes",
"shared": "Shared with me", "shared": "Shared with me",
"public": "Public notes" "public": "Public notes"
},
"filters": {
"byType": {
"title": "Filter by type",
"all": "All notes",
"documents": "Only documents",
"slides": "Only slides"
},
"byTag": "Show all notes with this tag",
"bySearchTerm": "Search term or tag name"
},
"sort": {
"asc": "(ascending)",
"desc": "(descending)",
"byCreationDate": "Created at {{direction}}",
"byLastVisited": "Last visited {{direction}}",
"byTitle": "Title {{direction}}"
} }
}, },
"renderer": { "renderer": {

View file

@ -5,10 +5,11 @@
*/ */
import type { NextPage } from 'next' import type { NextPage } from 'next'
import { LandingLayout } from '../../../../components/landing-layout/landing-layout' import { ExploreNotesSection } from '../../../../components/explore-page/explore-notes-section/explore-notes-section'
import { Mode } from '../../../../components/explore-page/mode-selection/mode'
const ExploreMyNotesPage: NextPage = () => { const ExploreMyNotesPage: NextPage = () => {
return <LandingLayout>Own Notes</LandingLayout> return <ExploreNotesSection mode={Mode.MY_NOTES} />
} }
export default ExploreMyNotesPage export default ExploreMyNotesPage

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
'use client'
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment } from 'react'
import type { Mode } from '../mode-selection/mode'
import type { NoteType } from '@hedgedoc/commons'
import { FilterByNoteType } from './filters/filter-by-note-type'
import { FilterBySearchTerm } from './filters/filter-by-search-term'
import { useUrlParamState } from '../../../hooks/common/use-url-param-state'
import { SortButton, SortMode } from './filters/sort-button'
export interface ExploreNotesSectionProps {
mode: Mode
}
export const ExploreNotesSection: React.FC<ExploreNotesSectionProps> = ({ mode }) => {
const [searchFilter, setSearchFilter] = useUrlParamState<string | null>('search', null)
const [sortMode, setSortMode] = useUrlParamState<SortMode>('sort', SortMode.LAST_VISITED_DESC)
const [filterByType, setFilterByType] = useUrlParamState<NoteType | null>('type', null)
return (
<Fragment>
<search className={'d-flex gap-2'}>
<FilterByNoteType value={filterByType} onChange={setFilterByType} />
<FilterBySearchTerm value={searchFilter} onChange={setSearchFilter} />
<SortButton selected={sortMode} onChange={setSortMode} />
</search>
<p>Many notes here</p>
</Fragment>
)
}

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo } from 'react'
import { NoteType } from '@hedgedoc/commons'
import { ButtonGroup, Dropdown, DropdownButton } from 'react-bootstrap'
import { NoteTypeIcon } from '../../../common/note-type-icon/note-type-icon'
import { Trans, useTranslation } from 'react-i18next'
import { useTranslatedText } from '../../../../hooks/common/use-translated-text'
import { UiIcon } from '../../../common/icons/ui-icon'
import { Funnel as IconFunnel } from 'react-bootstrap-icons'
export interface FilterByNoteTypeProps {
value: NoteType | null
onChange: (value: NoteType | null) => void
}
export const FilterByNoteType: React.FC<FilterByNoteTypeProps> = ({ value, onChange }) => {
useTranslation()
const labelInitial = useTranslatedText('explore.filters.byType.title')
const labels = useMemo(
() => ({
[NoteType.DOCUMENT]: (
<>
<NoteTypeIcon noteType={NoteType.DOCUMENT} className={'me-2'} />
<Trans i18nKey={'explore.filters.byType.documents'} />
</>
),
[NoteType.SLIDE]: (
<>
<NoteTypeIcon noteType={NoteType.SLIDE} className={'me-2'} />
<Trans i18nKey={'explore.filters.byType.slides'} />
</>
)
}),
[]
)
const onClickDropdownItem = useCallback(
(eventKey: string | null) => {
if (eventKey === '' || eventKey === null) {
onChange(null)
} else {
onChange(eventKey as NoteType)
}
},
[onChange]
)
return (
<DropdownButton
as={ButtonGroup}
variant={'secondary'}
title={
value === null ? (
<>
<UiIcon icon={IconFunnel} />
<span className={'ms-1'}>{labelInitial}</span>
</>
) : (
labels[value]
)
}
id={'filter-by-type'}
onSelect={onClickDropdownItem}>
<Dropdown.Item eventKey={''}>
<Trans i18nKey={'explore.filters.byType.all'} />
</Dropdown.Item>
<Dropdown.Item eventKey={NoteType.DOCUMENT}>{labels[NoteType.DOCUMENT]}</Dropdown.Item>
<Dropdown.Item eventKey={NoteType.SLIDE}>{labels[NoteType.SLIDE]}</Dropdown.Item>
</DropdownButton>
)
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ChangeEvent } from 'react'
import React, { useCallback } from 'react'
import { Form } from 'react-bootstrap'
export interface FilterBySearchTermProps {
value: string | null
onChange: (value: string | null) => void
}
export const FilterBySearchTerm: React.FC<FilterBySearchTermProps> = ({ value, onChange }) => {
const onInputSearchTerm = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const searchTerm = event.target.value
if (searchTerm === '') {
onChange(null)
} else {
onChange(searchTerm)
}
},
[onChange]
)
return (
<Form.Control
value={value ?? ''}
size={'sm'}
type={'search'}
placeholder={'Search term or tag name'}
onInput={onInputSearchTerm}
/>
)
}

View file

@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useTranslatedText } from '../../../../hooks/common/use-translated-text'
import { ButtonGroup, Dropdown, DropdownButton } from 'react-bootstrap'
import { UiIcon } from '../../../common/icons/ui-icon'
import {
SortAlphaUp as IconSortAlphaUp,
SortAlphaDown as IconSortAlphaDown,
SortUp as IconSortUp,
SortDown as IconSortDown
} from 'react-bootstrap-icons'
export enum SortMode {
TITLE_ASC = 'title_asc',
TITLE_DESC = 'title_desc',
CREATED_AT_ASC = 'created_at_asc',
CREATED_AT_DESC = 'created_at_desc',
LAST_VISITED_ASC = 'last_visited_asc',
LAST_VISITED_DESC = 'last_visited_desc'
}
export interface SortButtonProps {
selected: SortMode
onChange: (mode: SortMode) => void
}
export const SortButton: React.FC<SortButtonProps> = ({ selected, onChange }) => {
useTranslation()
const labelAscending = useTranslatedText('explore.sort.asc')
const labelDescending = useTranslatedText('explore.sort.desc')
const labels = useMemo(
() => ({
[SortMode.TITLE_ASC]: (
<>
<UiIcon icon={IconSortAlphaDown} className={'me-1'} />
<Trans i18nKey={'explore.sort.byTitle'} values={{ direction: labelAscending }} />
</>
),
[SortMode.TITLE_DESC]: (
<>
<UiIcon icon={IconSortAlphaUp} className={'me-1'} />
<Trans i18nKey={'explore.sort.byTitle'} values={{ direction: labelDescending }} />
</>
),
[SortMode.CREATED_AT_ASC]: (
<>
<UiIcon icon={IconSortUp} className={'me-1'} />
<Trans i18nKey={'explore.sort.byCreationDate'} values={{ direction: labelAscending }} />
</>
),
[SortMode.CREATED_AT_DESC]: (
<>
<UiIcon icon={IconSortDown} className={'me-1'} />
<Trans i18nKey={'explore.sort.byCreationDate'} values={{ direction: labelDescending }} />
</>
),
[SortMode.LAST_VISITED_ASC]: (
<>
<UiIcon icon={IconSortUp} className={'me-1'} />
<Trans i18nKey={'explore.sort.byLastVisited'} values={{ direction: labelAscending }} />
</>
),
[SortMode.LAST_VISITED_DESC]: (
<>
<UiIcon icon={IconSortDown} className={'me-1'} />
<Trans i18nKey={'explore.sort.byLastVisited'} values={{ direction: labelDescending }} />
</>
)
}),
[labelAscending, labelDescending]
)
const onClickDropdownItem = useCallback(
(eventKey: string | null) => {
onChange(eventKey as SortMode)
},
[onChange]
)
return (
<DropdownButton
as={ButtonGroup}
variant={'secondary'}
title={labels[selected]}
id={'filter-by-type'}
onSelect={onClickDropdownItem}>
<Dropdown.Item eventKey={SortMode.LAST_VISITED_DESC}>{labels[SortMode.LAST_VISITED_DESC]}</Dropdown.Item>
<Dropdown.Item eventKey={SortMode.LAST_VISITED_ASC}>{labels[SortMode.LAST_VISITED_ASC]}</Dropdown.Item>
<Dropdown.Item eventKey={SortMode.CREATED_AT_DESC}>{labels[SortMode.CREATED_AT_DESC]}</Dropdown.Item>
<Dropdown.Item eventKey={SortMode.CREATED_AT_ASC}>{labels[SortMode.CREATED_AT_ASC]}</Dropdown.Item>
<Dropdown.Item eventKey={SortMode.TITLE_ASC}>{labels[SortMode.TITLE_ASC]}</Dropdown.Item>
<Dropdown.Item eventKey={SortMode.TITLE_DESC}>{labels[SortMode.TITLE_DESC]}</Dropdown.Item>
</DropdownButton>
)
}

View file

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Dispatch, SetStateAction } from 'react'
import { useEffect, useRef } from 'react'
import { useCallback, useState } from 'react'
import { useSearchParams } from 'next/navigation'
const extractSearchParam = (paramName: string): string | null => {
const searchParams = new URLSearchParams(window.location.search)
return searchParams.get(paramName)
}
export const useUrlParamState = <T extends string | null = string>(
paramName: string,
defaultValue: T | (() => T)
): [T, Dispatch<SetStateAction<T>>] => {
const searchParamsReact = useSearchParams()
const lastSetValue = useRef<T>()
const [value, setValue] = useState<T>(() => {
const paramValue = extractSearchParam(paramName)
if (paramValue !== null) {
lastSetValue.current = paramValue as T
return paramValue as T
} else {
const initialValue = typeof defaultValue === 'function' ? defaultValue() : defaultValue
lastSetValue.current = initialValue
return initialValue
}
})
const onUpdate = useCallback(
(newValue: T | ((oldValue: T) => T)) => {
setValue((oldValue) => {
const finalValue = typeof newValue === 'function' ? newValue(oldValue) : newValue
if (finalValue === lastSetValue.current) {
return finalValue
}
lastSetValue.current = finalValue
const searchParams = new URLSearchParams(window.location.search)
if (finalValue) {
searchParams.set(paramName, finalValue)
} else {
searchParams.delete(paramName)
}
window.history.replaceState(
{
[paramName]: finalValue
},
'',
`?${searchParams}`
)
return finalValue
})
},
[paramName]
)
useEffect(() => {
if (!searchParamsReact) {
return
}
const newValue = searchParamsReact.get(paramName) as T
if (newValue === lastSetValue.current) {
return
}
lastSetValue.current = newValue
setValue(newValue)
}, [paramName, searchParamsReact])
return [value, onUpdate]
}