frontend: load/show first 100 entries to react faster to user navigation

Massimo Melina committed Dec 19, 2021 at 21:45 UTC dae82cd7929643e5e6f475f4591cb76b5b31a859
5 files changed +66 -25
frontend/src/BrowseFiles.ts
+37 -13
@@ -1,34 +1,58 @@
1 import { Link, useLocation } from 'react-router-dom'
2 import { useApi } from './api'
3 -import { createElement as h, Fragment } from 'react'
4 -import { formatBytes, hError, hIcon, Loading } from './misc'
3 +import { createContext, createElement as h, Fragment, useContext, useEffect, useMemo, useState } from 'react'
4 +import { formatBytes, hError, hIcon, Loading, Spinner } from './misc'
5 import { Head } from './Head'
6
7 function usePath() {
8 return decodeURI(useLocation().pathname)
9 }
10
11 +export const ListContext = createContext<{ list:DirList, unfinished:boolean }>({ list:[], unfinished: false })
12 +
13 export function BrowseFiles() {
12 - const path = usePath()
13 - let res = useApi('file_list', { path })
14 - if (!res)
14 + const [list, unfinished] = useFetchList()
15 + if (!list)
16 return h(Loading)
16 - if (res instanceof Error)
17 - return hError(res)
18 - const { list } = res
19 - return h(Fragment, {},
20 - h(Head, { list }),
21 - h(FilesList, { list }))
17 + if (list instanceof Error)
18 + return hError(list)
19 + return h(ListContext.Provider, { value:{ list, unfinished } },
20 + h(Head),
21 + h(FilesList))
22 +}
23 +
24 +function useFetchList() {
25 + const desiredPath = usePath()
26 + const PRELOAD_SIZE = 100
27 + const [preloading, setPreloading] = useState(true)
28 + const [path, setPath] = useState('')
29 + useEffect(()=>{
30 + setPreloading(true)
31 + setPath(desiredPath)
32 + }, [desiredPath])
33 + const API = 'file_list'
34 + const preload = useApi(path && API, { path, limit: PRELOAD_SIZE })
35 + const rest = useApi(!preloading && API, { path, offset: PRELOAD_SIZE })
36 + const list = useMemo(() => !preload ? null
37 + : !rest ? (preload.list||preload) // the || is for an Error instance
38 + : [...preload.list, ...rest.list],
39 + [preload, rest])
40 + const unfinished = preload && !rest && list.length === PRELOAD_SIZE
41 + if (unfinished && preloading) // let load it all
42 + setPreloading(false)
43 + return [ list, unfinished ]
44 }
45
46 interface DirEntry { n:string, s?:number, m?:string, c?:string }
47 export type DirList = DirEntry[]
48
27 -function FilesList({ list }:{ list:DirList }) {
49 +function FilesList() {
50 + const { list, unfinished } = useContext(ListContext)
51 return h('ul', { className: 'dir' },
52 !list.length ? 'Nothing here'
53 : list.map((entry: DirEntry) =>
31 - h(File, { key: entry.n, ...entry })))
54 + h(File, { key: entry.n, ...entry })),
55 + unfinished && h(Spinner))
56 }
57
58 function File({ n, m, c, s }: DirEntry) {
frontend/src/Head.ts
+8 -6
@@ -1,15 +1,15 @@
1 -import { createElement as h, Fragment, useMemo } from 'react'
1 +import { createElement as h, Fragment, useContext, useMemo } from 'react'
2 import { Link, useLocation } from 'react-router-dom'
3 -import { DirList } from './BrowseFiles'
3 +import { ListContext } from './BrowseFiles'
4 import { login, logout } from './login'
5 -import { formatBytes, hIcon, prefix } from './misc'
5 +import { formatBytes, hIcon, prefix, Spinner } from './misc'
6 import { useSnapState } from './state'
7
8 -export function Head({ list }:{ list:DirList }) {
8 +export function Head() {
9 return h(Fragment, {},
10 h(MenuPanel),
11 h(Breadcrumbs),
12 - h(FolderStats, { list })
12 + h(FolderStats)
13 )
14 }
15
@@ -44,7 +44,8 @@ function MenuButton({ icon, label, onClick }:{ icon:string, label:string, onClic
44 h('label',{}, label))
45 }
46
47 -function FolderStats({ list }:{ list:DirList }) {
47 +function FolderStats() {
48 + const { list, unfinished } = useContext(ListContext)
49 const stats = useMemo(() =>{
50 let files = 0, folders = 0, size = 0
51 for (const x of list) {
@@ -57,6 +58,7 @@ function FolderStats({ list }:{ list:DirList }) {
58 return { files, folders, size }
59 }, [list])
60 return h('div', { id:'folder-stats' },
61 + unfinished && h(Spinner),
62 [
63 prefix('', stats.files,' file(s)'),
64 prefix('', stats.folders, ' folder(s)'),
frontend/src/api.ts
+5 -3
@@ -1,4 +1,5 @@
1 import { useEffect, useState } from 'react';
2 +import { Falsy } from './misc'
3
4 export function apiCall(cmd: string, params?: object) : Promise<any> {
5 return fetch('/~/api/'+cmd, {
@@ -14,11 +15,12 @@ export function apiCall(cmd: string, params?: object) : Promise<any> {
15 })
16 }
17
17 -export function useApi(cmd: string, params?: object) : any {
18 +export function useApi(cmd: string | Falsy, params?: object) : any {
19 const [x, setX] = useState()
20 useEffect(()=>{
21 setX(undefined)
21 - apiCall(cmd, params).then(setX, setX)
22 + if (cmd)
23 + apiCall(cmd, params).then(setX, setX)
24 }, [cmd, JSON.stringify(params)]) //eslint-disable-line
25 return x
24 -}
\ No newline at end of file
26 +}
frontend/src/index.scss
+5
@@ -37,6 +37,11 @@ button {
37 color: #833;
38 }
39
40 +@keyframes spin {
41 + from { transform: rotate(0deg); }
42 + to { transform: rotate(360deg); }
43 +}
44 +
45 #folder-path {
46 margin: 0.2em;
47 & a {
frontend/src/misc.ts
+11 -3
@@ -1,4 +1,6 @@
1 -import React, { createElement as h } from 'react'
1 +import { createElement as h } from 'react'
2 +
3 +export type Falsy = false | null | undefined | '' | 0
4
5 export function hIcon(name: string) {
6 return h(Icon, { name })
@@ -12,16 +14,22 @@ const SYS_ICONS: Record<string,string> = {
14 login: 'person',
15 user: 'account_circle',
16 file: 'description',
17 + spinner: 'sports_baseball',
18 }
16 -export function Icon({ name }: { name:string }) {
19 +export function Icon({ name, ...props }: { name:string }) {
20 name = SYS_ICONS[name] || name
21 return h('span',{
22 className: 'material-icons-outlined icon',
23 + ...props
24 }, name)
25 }
26
27 export function Loading() {
24 - return 'loading' as React.ReactNode as React.ReactElement
28 + return h(Spinner)
29 +}
30 +
31 +export function Spinner() {
32 + return h(Icon, { name:'spinner', style: { animation:'1s spin infinite' } })
33 }
34
35 export function formatBytes(n: number, post: string = 'B') {