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