frontend: scroll through pages
Massimo Melina committed
Feb 9, 2023 at 00:49 UTC
8abea6193873d920c19928393159705a4d5107d2
4 files changed
+91
-20
frontend/src/BrowseFiles.ts
+69
-19
@@ -1,8 +1,8 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { Link, useLocation } from 'react-router-dom'
4
-import { createElement as h, Fragment, memo, useEffect, useMemo, useState } from 'react'
5
-import { formatBytes, hError, hfsEvent, hIcon, isMobile } from './misc'
4
+import { createElement as h, Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+import { domOn, formatBytes, hError, hfsEvent, hIcon, isMobile } from './misc'
6
import { Checkbox, Html, Spinner } from './components'
7
import { Head } from './Head'
8
import { state, useSnapState } from './state'
@@ -10,6 +10,7 @@ import { alertDialog } from './dialog'
10
import useFetchList from './useFetchList'
11
import useAuthorized from './useAuthorized'
12
import { acceptDropFiles, enqueue } from './upload'
13
+import _ from 'lodash'
14
15
export function usePath() {
16
return decodeURI(useLocation().pathname)
@@ -34,43 +35,87 @@ function FilesList() {
35
const midnight = useMidnight() // as an optimization we calculate this only once per list and pass it down
36
const pageSize = 100
37
const [page, setPage] = useState(0)
38
+ const [extraPages, setExtraPages] = useState(0)
39
+ const [scrolledPages, setScrolledPages] = useState(0)
40
const offset = page * pageSize
41
const theList = filteredList || list
42
const total = theList.length
43
+ const nPages = Math.ceil(total / pageSize)
44
45
useEffect(() => setPage(0), [theList])
42
- useEffect(() => document.scrollingElement?.scrollTo(0,0), [page])
46
+ useEffect(() => {
47
+ document.scrollingElement?.scrollTo(0, 0)
48
+ setExtraPages(0)
49
+ setScrolledPages(0)
50
+ }, [page])
51
+ const calcScrolledPages = useMemo(() =>
52
+ _.throttle(() => {
53
+ const i = _.findLastIndex(document.querySelectorAll('.' + PAGE_SEPARATOR_CLASS), el =>
54
+ el.getBoundingClientRect().top <= window.innerHeight/2)
55
+ setScrolledPages(i + 1)
56
+ }, 200),
57
+ [])
58
+ useEffect(() => domOn('scroll', () => {
59
+ if (!theList.length) return
60
+ const timeToAdd = window.innerHeight * 1.3 + window.scrollY >= document.body.offsetHeight // 30vh before the end
61
+ if (timeToAdd && page + extraPages < nPages -1)
62
+ setExtraPages(extraPages+1)
63
+ calcScrolledPages()
64
+ }), [page, extraPages, nPages])
65
+
66
+ const ref = useRef<HTMLElement>()
67
+
68
+ const pageChange = useCallback((i: number) => {
69
+ if (i < page || i > page + extraPages)
70
+ return setPage(i)
71
+ i -= page + 1
72
+ const el = i < 0 ? ref.current?.querySelector('*')
73
+ : document.querySelectorAll('.' + PAGE_SEPARATOR_CLASS)[i]
74
+ el?.scrollIntoView({ block: 'center' })
75
+ }, [page, extraPages])
76
77
return h(Fragment, {},
45
- h('ul', { className: 'dir', ...acceptDropFiles(can_upload && enqueue) },
78
+ h('ul', { ref, className: 'dir', ...acceptDropFiles(can_upload && enqueue) },
79
!list.length ? (!loading && (stoppedSearch ? "Stopped before finding anything" : "Nothing here"))
80
: filteredList && !filteredList.length ? "No match for this filter"
48
- : theList.slice(offset, offset + pageSize).map((entry: DirEntry) =>
49
- h(Entry, { key: entry.n, midnight, ...entry })),
81
+ : theList.slice(offset, offset + pageSize * (1+extraPages)).map((entry: DirEntry, idx) =>
82
+ h(Entry, {
83
+ key: entry.n,
84
+ midnight,
85
+ separator: idx > 0 && !(idx % pageSize) ? String(offset + idx) : undefined,
86
+ ...entry
87
+ })),
88
loading && h(Spinner),
89
),
52
- total > pageSize && h(Paging, { total, current:page, pageSize, pageChange:setPage })
90
+ total > pageSize && h(Paging, {
91
+ nPages,
92
+ current: page + scrolledPages,
93
+ pageSize,
94
+ pageChange,
95
+ })
96
)
97
}
98
99
interface PagingProps {
57
- total: number
100
+ nPages: number
101
current: number
102
pageSize: number
103
pageChange:(newPage:number) => void
104
}
62
-function Paging({ total, current, pageSize, pageChange }: PagingProps) {
63
- const nPages = Math.ceil(total / pageSize)
105
+const Paging = memo(({ nPages, current, pageSize, pageChange }: PagingProps) => {
106
+ const ref = useRef<HTMLElement>()
107
const pages = []
108
for (let i=0; i<nPages; i++)
109
pages.push(h('button', {
67
- ...i===current && { className:'toggled' },
110
+ ...i===current && { className:'toggled', ref },
111
+ //@ts-ignore
112
onClick(){
113
pageChange(i)
114
}
71
- }, i*pageSize || 1))
115
+ }, i*pageSize || "Page 1"))
116
+ useEffect(() => ref.current?.scrollIntoView({ block: 'nearest' }), [current])
117
return h('div', { id:'paging' }, ...pages)
73
-}
118
+})
119
120
function useMidnight() {
121
const [midnight, setMidnight] = useState(calcMidnight)
@@ -88,14 +133,19 @@ function useMidnight() {
133
}
134
}
135
91
-const Entry = memo(function(entry: DirEntry & { midnight: Date }) {
92
- let { n: relativePath, isFolder } = entry
136
+const PAGE_SEPARATOR_CLASS = 'page-separator'
137
+
138
+const Entry = memo((entry: DirEntry & { midnight: Date, separator?: string }) => {
139
+ let { n: relativePath, isFolder, separator } = entry
140
const base = usePath()
141
const { showFilter, selected } = useSnapState()
142
const href = fixUrl(relativePath)
143
const containerDir = isFolder ? '' : relativePath.substring(0, relativePath.lastIndexOf('/')+1)
144
const name = relativePath.substring(containerDir.length)
98
- return h('li', { className: isFolder ? 'folder' : 'file' },
145
+ let className = isFolder ? 'folder' : 'file'
146
+ if (separator)
147
+ className += ' ' + PAGE_SEPARATOR_CLASS
148
+ return h('li', { className, label: separator },
149
showFilter && h(Checkbox, {
150
value: selected[relativePath],
151
onChange(v){
@@ -124,10 +174,10 @@ const EntryProps = memo(function(entry: DirEntry & { midnight: Date }) {
174
const shortTs = isMobile()
175
const code = useMemo(()=> hfsEvent('additionalEntryProps', { entry }).join(''),
176
[entry])
127
- return h('div', { className:'entry-props' },
128
- h(Html, { code, className:'add-props' }),
177
+ return h('div', { className: 'entry-props' },
178
+ h(Html, { code, className: 'add-props' }),
179
s !== undefined && h(Fragment, {},
130
- h('span', { className:'entry-size' }, formatBytes(s)),
180
+ h('span', { className: 'entry-size' }, formatBytes(s)),
181
" — ",
182
),
183
t && h('span', {
frontend/src/index.scss
+15
@@ -209,6 +209,20 @@ ul.dir {
209
display:inline-block;
210
}
211
}
212
+ &.page-separator {
213
+ border-top: 4px dashed var(--button-bg);
214
+ margin-top: 1em;
215
+ padding-top: 1em;
216
+ position: relative;
217
+ &::before {
218
+ content: "Page " attr(label);
219
+ position: absolute;
220
+ top: -.8em;
221
+ background: var(--bg);
222
+ padding: 0 .5em;
223
+ font-size: smaller;
224
+ }
225
+ }
226
}
227
}
228
@@ -271,6 +285,7 @@ button label {
285
flex: 1;
286
background: var(--button-bg);
287
text-align: center;
288
+ white-space: nowrap;
289
}
290
}
291
shared/index.ts
+5
-1
@@ -79,4 +79,8 @@ export function try_(cb: () => any, onException?: (e:any) => any) {
79
catch(e) {
80
return onException?.(e)
81
}
82
-}
\ No newline at end of file
82
+}
83
+export function domOn<K extends keyof WindowEventMap>(eventName: K, cb: (ev: WindowEventMap[K]) => void, { target=window }={}) {
84
+ target.addEventListener(eventName, cb)
85
+ return () => target.removeEventListener(eventName, cb)
86
+}
todo.md
+2
@@ -1,4 +1,6 @@
1
# To do
2
+- frontend: new-folder button in upload
3
+- frontend: button to reach the bottom of the list
4
- admin/fs: check if source exists when set
5
- plugins: after installing, switch to installed (and perhaps highlight new one)
6
- plugins' log, accessible in admin