list filter
Massimo Melina committed
Dec 20, 2021 at 16:11 UTC
5f18e20aa421d4fab6498be7fead4f82087dee3e
8 files changed
+86
-26
dev-notes.md
-1
@@ -1,5 +1,4 @@
1
# To do
2
-- filter list
2
- search
3
try to use server-sent events for the reply
4
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
frontend/package-lock.json
+18
@@ -11,6 +11,7 @@
11
"react": "^17.0.2",
12
"react-dom": "^17.0.2",
13
"react-router-dom": "^6.1.1",
14
+ "use-debounce": "^7.0.1",
15
"valtio": "^1.2.7",
16
"web-vitals": "^1.0.1"
17
},
@@ -17325,6 +17326,17 @@
17326
"integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=",
17327
"dev": true
17328
},
17329
+ "node_modules/use-debounce": {
17330
+ "version": "7.0.1",
17331
+ "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-7.0.1.tgz",
17332
+ "integrity": "sha512-fOrzIw2wstbAJuv8PC9Vg4XgwyTLEOdq4y/Z3IhVl8DAE4svRcgyEUvrEXu+BMNgMoc3YND6qLT61kkgEKXh7Q==",
17333
+ "engines": {
17334
+ "node": ">= 10.0.0"
17335
+ },
17336
+ "peerDependencies": {
17337
+ "react": ">=16.8.0"
17338
+ }
17339
+ },
17340
"node_modules/util-deprecate": {
17341
"version": "1.0.2",
17342
"dev": true,
@@ -31193,6 +31205,12 @@
31205
}
31206
}
31207
},
31208
+ "use-debounce": {
31209
+ "version": "7.0.1",
31210
+ "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-7.0.1.tgz",
31211
+ "integrity": "sha512-fOrzIw2wstbAJuv8PC9Vg4XgwyTLEOdq4y/Z3IhVl8DAE4svRcgyEUvrEXu+BMNgMoc3YND6qLT61kkgEKXh7Q==",
31212
+ "requires": {}
31213
+ },
31214
"util-deprecate": {
31215
"version": "1.0.2",
31216
"dev": true
frontend/package.json
+1
@@ -14,6 +14,7 @@
14
"react": "^17.0.2",
15
"react-dom": "^17.0.2",
16
"react-router-dom": "^6.1.1",
17
+ "use-debounce": "^7.0.1",
18
"valtio": "^1.2.7",
19
"web-vitals": "^1.0.1"
20
},
frontend/src/BrowseFiles.ts
+11
-4
@@ -4,6 +4,8 @@ import { createContext, createElement as h, Fragment, useContext, useEffect, use
4
import { formatBytes, hError, hIcon } from './misc'
5
import { Loading, Spinner } from './components'
6
import { Head } from './Head'
7
+import { state, useSnapState } from './state'
8
+import _ from 'lodash'
9
10
function usePath() {
11
return decodeURI(useLocation().pathname)
@@ -49,19 +51,24 @@ export type DirList = DirEntry[]
51
52
function FilesList() {
53
const { list, unfinished } = useContext(ListContext)
52
- return h('ul', { className: 'dir' },
54
+ const snap = useSnapState()
55
+ const filter = snap.listFilter > '' && new RegExp(_.escapeRegExp(snap.listFilter),'i')
56
+ let n = 0 // if I try to use directly the state as counter I get a "too many re-renders" error
57
+ const ret = h('ul', { className: 'dir' },
58
!list.length ? 'Nothing here'
59
: list.map((entry: DirEntry) =>
55
- h(File, { key: entry.n, ...entry })),
60
+ h(File, { key: entry.n, hidden: filter && !filter.test(entry.n) || !++n, ...entry })),
61
unfinished && h(Spinner))
62
+ state.filteredEntries = filter ? n : -1
63
+ return ret
64
}
65
59
-function File({ n, m, c, s }: DirEntry) {
66
+function File({ n, m, c, s, hidden }: DirEntry & { hidden:boolean }) {
67
const base = usePath()
68
const isDir = n.endsWith('/')
69
const t = m||c ||null
70
const href = n.replace(/#/g, encodeURIComponent)
64
- return h('li', {},
71
+ return h('li', { style:hidden ? { display:'none' } : null },
72
isDir ? h(Link, { to: base+href }, hIcon('folder'), n)
73
: h('a', { href }, hIcon('file'), n),
74
h('div', { className:'entry-props' },
frontend/src/Head.ts
+46
-20
@@ -1,10 +1,11 @@
1
-import { createElement as h, Fragment, useContext, useMemo } from 'react'
1
+import { createElement as h, Fragment, useContext, useMemo, useState } from 'react'
2
import { Link, useLocation } from 'react-router-dom'
3
import { ListContext } from './BrowseFiles'
4
import { login, logout } from './login'
5
import { formatBytes, hIcon, prefix } from './misc'
6
import { Spinner } from './components'
7
-import { useSnapState } from './state'
7
+import { state, useSnapState } from './state'
8
+import { useDebounce } from 'use-debounce'
9
10
export function Head() {
11
return h(Fragment, {},
@@ -17,28 +18,29 @@ export function Head() {
18
}
19
20
function MenuPanel() {
20
- const snap = useSnapState()
21
+ const [showFilter, setShowFilter] = useState(false)
22
+ const [filter, setFilter] = useState('')
23
+ ;[state.listFilter] = useDebounce(filter, 300)
24
return h('div', { id:'menu-panel' },
25
h('div', { id:'menu-bar' },
23
- MenuButton(snap.username ? {
24
- icon: 'user',
25
- label: snap.username,
26
- onClick(){
27
- if (window.confirm('Logout?'))
28
- logout()
29
- },
30
- } : {
31
- icon: 'login',
32
- label: 'Login',
33
- async onClick(){
34
- const user = prompt('Username')
35
- if (!user) return
36
- const password = prompt('Password')
37
- if (!password) return
38
- await login(user, password)
26
+ h(LoginButton),
27
+ h(MenuButton, {
28
+ icon: 'filter',
29
+ label: 'Filter',
30
+ onClick() {
31
+ setShowFilter(!showFilter)
32
}
33
})
41
- ))
34
+ ),
35
+ showFilter && h('input',{
36
+ id:'filter',
37
+ value: filter,
38
+ autoFocus: true,
39
+ onChange(ev) {
40
+ setFilter(ev.target.value)
41
+ }
42
+ })
43
+ )
44
}
45
46
function MenuButton({ icon, label, onClick }:{ icon:string, label:string, onClick?:()=>void }) {
@@ -47,6 +49,28 @@ function MenuButton({ icon, label, onClick }:{ icon:string, label:string, onClic
49
h('label',{}, label))
50
}
51
52
+function LoginButton() {
53
+ const snap = useSnapState()
54
+ return MenuButton(snap.username ? {
55
+ icon: 'user',
56
+ label: snap.username,
57
+ onClick(){
58
+ if (window.confirm('Logout?'))
59
+ logout()
60
+ },
61
+ } : {
62
+ icon: 'login',
63
+ label: 'Login',
64
+ async onClick(){
65
+ const user = prompt('Username')
66
+ if (!user) return
67
+ const password = prompt('Password')
68
+ if (!password) return
69
+ await login(user, password)
70
+ }
71
+ })
72
+}
73
+
74
function FolderStats() {
75
const { list, unfinished } = useContext(ListContext)
76
const stats = useMemo(() =>{
@@ -60,12 +84,14 @@ function FolderStats() {
84
}
85
return { files, folders, size }
86
}, [list])
87
+ const snap = useSnapState()
88
return h('div', { id:'folder-stats' },
89
unfinished && h(Spinner),
90
[
91
prefix('', stats.files,' file(s)'),
92
prefix('', stats.folders, ' folder(s)'),
93
stats.size ? formatBytes(stats.size) : '',
94
+ snap.filteredEntries >= 0 && snap.filteredEntries+' displayed',
95
].filter(Boolean).join(', ')
96
)
97
}
frontend/src/components.ts
+1
@@ -6,6 +6,7 @@ const SYS_ICONS: Record<string,string> = {
6
user: 'account_circle',
7
file: 'description',
8
spinner: 'sports_baseball',
9
+ filter: 'filter_alt',
10
}
11
12
const iconClass = ICON_FONT_NAME.then(v => v.replace(/ /g,'-').toLowerCase())
frontend/src/index.scss
+6
@@ -65,6 +65,12 @@ header {
65
position: sticky;
66
top: 0;
67
}
68
+#filter {
69
+ width: calc(100% - 0.5em);
70
+ margin: 0.2em auto;
71
+ box-sizing: border-box;
72
+ display: block;
73
+}
74
75
ul.dir {
76
padding: 0;
frontend/src/state.ts
+3
-1
@@ -1,7 +1,9 @@
1
import { proxy, useSnapshot } from 'valtio'
2
3
export const state = proxy({
4
- username: ''
4
+ username: '',
5
+ listFilter: '',
6
+ filteredEntries: -1,
7
})
8
9
export function useSnapState() {