mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-08 10:22:47 -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",
|
"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": {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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