mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-08 02:15:02 -04:00
feat(explore): add filters for explore page note list
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
869366a744
commit
0a9b965b72
7 changed files with 349 additions and 2 deletions
|
@ -21,6 +21,23 @@
|
|||
"my": "My notes",
|
||||
"shared": "Shared with me",
|
||||
"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": {
|
||||
|
|
|
@ -5,10 +5,11 @@
|
|||
*/
|
||||
|
||||
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 = () => {
|
||||
return <LandingLayout>Own Notes</LandingLayout>
|
||||
return <ExploreNotesSection mode={Mode.MY_NOTES} />
|
||||
}
|
||||
|
||||
export default ExploreMyNotesPage
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
74
frontend/src/hooks/common/use-url-param-state.ts
Normal file
74
frontend/src/hooks/common/use-url-param-state.ts
Normal 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]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue