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() {