button to invert selection
Massimo Melina committed
Jan 22, 2022 at 12:38 UTC
83dbd578e6a9122672f68fafbd092979ab10b7b8
12 files changed
+120
-85
frontend/public/fontello.css
+3
-2
@@ -1,6 +1,6 @@
1
@font-face {
2
font-family: 'fontello';
3
- src: url('fontello.woff2?71539586') format('woff2');
3
+ src: url('fontello.woff2?6810240') format('woff2');
4
font-weight: normal;
5
font-style: normal;
6
}
@@ -10,7 +10,7 @@
10
@media screen and (-webkit-min-device-pixel-ratio:0) {
11
@font-face {
12
font-family: 'fontello';
13
- src: url('../font/fontello.svg?71539586#fontello') format('svg');
13
+ src: url('../font/fontello.svg?6810240#fontello') format('svg');
14
}
15
}
16
*/
@@ -62,6 +62,7 @@
62
.fa-user:before { content: '\e80a'; } /* '' */
63
.fa-home:before { content: '\e80b'; } /* '' */
64
.fa-key:before { content: '\e80c'; } /* '' */
65
+.fa-retweet:before { content: '\e80f'; } /* '' */
66
.fa-cancel-circled:before { content: '\e811'; } /* '' */
67
.fa-search:before { content: '\e813'; } /* '' */
68
.fa-logout:before { content: '\e814'; } /* '' */
frontend/public/fontello.woff2
Binary files a/frontend/public/fontello.woff2 and b/frontend/public/fontello.woff2 differ
frontend/src/Breadcrumbs.ts
+8
-5
@@ -1,8 +1,9 @@
1
import { Link, useLocation } from 'react-router-dom'
2
-import { createElement as h, Fragment, ReactElement, useContext } from 'react'
3
-import { ListContext } from './BrowseFiles'
2
+import { createElement as h, Fragment, ReactElement } from 'react'
3
import { confirmDialog } from './dialog'
4
import { hIcon } from './misc'
5
+import { state } from './state'
6
+import { reloadList } from './useFetchList'
7
8
export function Breadcrumbs() {
9
const currentPath = useLocation().pathname.slice(1,-1)
@@ -26,13 +27,15 @@ function Breadcrumb({ path, label, current }:{ current?: boolean, path?: string,
27
const PAD = '\u00A0\u00A0' // make small elements easier to tap. Don't use min-width 'cause it requires display-inline that breaks word-wrapping
28
if (typeof label === 'string' && label.length < 3)
29
label = PAD+label+PAD
29
- const { reload } = useContext(ListContext)
30
return h(Link, {
31
className: 'breadcrumb',
32
to: path || '/',
33
async onClick() {
34
- if (current && await confirmDialog('Reload?'))
35
- reload?.()
34
+ if (current && await confirmDialog('Reload?')) {
35
+ state.remoteSearch = ''
36
+ state.stopSearch?.()
37
+ reloadList()
38
+ }
39
}
40
}, label)
41
}
frontend/src/BrowseFiles.ts
+14
-21
@@ -1,45 +1,38 @@
1
import { Link, useLocation } from 'react-router-dom'
2
-import { createContext, createElement as h, Fragment, useContext, useEffect, useMemo, useState, memo } from 'react'
3
-import { formatBytes, hError, hIcon, hfsEvent } from './misc'
2
+import { createElement as h, Fragment, memo, useEffect, useMemo, useState } from 'react'
3
+import { formatBytes, hError, hfsEvent, hIcon } from './misc'
4
import { Checkbox, Html, Spinner } from './components'
5
import { Head } from './Head'
6
import { state, useSnapState } from './state'
7
-import _ from 'lodash'
8
-import useFetchList from './useFetchList'
7
import { alertDialog } from './dialog'
8
+import useFetchList from './useFetchList'
9
10
export function usePath() {
11
return decodeURI(useLocation().pathname)
12
}
13
14
export interface DirEntry { n:string, s?:number, m?:string, c?:string,
16
- ext:string, isFolder:boolean, t?:Date } // we memoize these value for speed
15
+ ext:string, isFolder:boolean, t?:Date, hidden?:boolean } // we memoize these value for speed
16
export type DirList = DirEntry[]
18
-interface ListRes { list:DirList, loading?:boolean, err?:Error, reload?:()=>void }
19
-
20
-export const ListContext = createContext<ListRes>({ list:[], loading: false })
17
18
export function BrowseFiles() {
23
- const { list, loading, error, reload } = useFetchList()
24
- return h(ListContext.Provider, { value:{ list, loading, reload } },
19
+ useFetchList()
20
+ const { error, list } = useSnapState()
21
+ return h(Fragment, {},
22
h(Head),
26
- hError(error && 'Failed to retrieve list') || h(list ? FilesList : Spinner))
23
+ hError(error && 'Failed to retrieve list')
24
+ || h(list ? FilesList : Spinner))
25
}
26
27
function FilesList() {
30
- const { list, loading } = useContext(ListContext)
28
const snap = useSnapState()
29
+ const { list, loading } = snap
30
const midnight = useMidnight() // as an optimization we calculate this only once per list
33
- if (!list) return null
34
- const filter = snap.listFilter > '' && new RegExp(_.escapeRegExp(snap.listFilter),'i')
35
- let n = 0 // if I try to use directly the state as counter I get a "too many re-renders" error
36
- const ret = h('ul', { className: 'dir' },
31
+ return h('ul', { className: 'dir' },
32
!list.length ? (!loading && (snap.stoppedSearch ? 'Stopped before finding anything' : 'Nothing here'))
33
: list.map((entry: DirEntry) =>
39
- h(Entry, { key: entry.n, midnight, hidden: filter && !filter.test(entry.n) || !++n, ...entry })),
34
+ h(Entry, { key: entry.n, midnight, ...entry })),
35
loading && h(Spinner))
41
- state.filteredEntries = filter ? n : -1
42
- return ret
36
}
37
38
function useMidnight() {
@@ -62,14 +55,14 @@ function isMobile() {
55
return window.innerWidth < 800
56
}
57
65
-const Entry = memo(function(entry: DirEntry & { hidden:boolean, midnight: Date }) {
58
+const Entry = memo(function(entry: DirEntry & { midnight: Date }) {
59
let { n: relativePath, hidden, isFolder } = entry
60
const base = usePath()
61
const { showFilter, selected } = useSnapState()
62
const href = fixUrl(relativePath)
63
const containerDir = isFolder ? '' : relativePath.substring(0, relativePath.lastIndexOf('/')+1)
64
const name = relativePath.substring(containerDir.length)
72
- return h('li', { className:isFolder ? 'folder' : 'file', style:hidden ? { display:'none' } : null },
65
+ return h('li', { className: (isFolder ? 'folder' : 'file') + (hidden ? ' hidden' : '') },
66
showFilter && h(Checkbox, {
67
value: selected[relativePath],
68
onChange(v){
frontend/src/Head.ts
+2
-4
@@ -1,5 +1,4 @@
1
-import { createElement as h, useContext, useMemo} from 'react'
2
-import { ListContext } from './BrowseFiles'
1
+import { createElement as h, useMemo} from 'react'
2
import { formatBytes, hIcon, prefix } from './misc'
3
import { Spinner } from './components'
4
import { useSnapState } from './state'
@@ -16,7 +15,7 @@ export function Head() {
15
}
16
17
function FolderStats() {
19
- const { list, loading } = useContext(ListContext)
18
+ const { list, loading, filteredEntries, selected, stoppedSearch } = useSnapState()
19
const stats = useMemo(() =>{
20
let files = 0, folders = 0, size = 0
21
for (const x of list) {
@@ -28,7 +27,6 @@ function FolderStats() {
27
}
28
return { files, folders, size }
29
}, [list])
31
- const { filteredEntries, selected, stoppedSearch } = useSnapState()
30
const sel = Object.keys(selected).length
31
return h('div', { id:'folder-stats' },
32
stoppedSearch ? hIcon('interrupted', { title:'Search was interrupted' })
frontend/src/icons.ts
+1
@@ -18,6 +18,7 @@ const SYS_ICONS = {
18
spinner: 'spin6 spinner:🎲',
19
password: 'key:🗝️',
20
download: ':📥',
21
+ invert: 'retweet:🙃',
22
}
23
24
document.fonts.ready.then(async ()=> {
frontend/src/index.scss
+15
@@ -51,6 +51,8 @@ input[type=checkbox] {
51
accent-color: var(--button-bg);
52
}
53
54
+.hidden { display: none !important }
55
+
56
.icon {
57
font-size: 1.2em;
58
}
@@ -132,6 +134,14 @@ header input {
134
margin: 0.2em auto;
135
box-sizing: border-box;
136
}
137
+#filter-bar {
138
+ display: flex;
139
+ gap: .3em;
140
+ & input { flex: 1 }
141
+ & button {
142
+ padding: 0 0.5em;
143
+ }
144
+}
145
146
ul.dir {
147
padding: 0;
@@ -204,6 +214,11 @@ button label {
214
align-content: stretch;
215
& button label { display: none } /* icons only */
216
}
217
+ #filter-bar label { display:none }
218
+ #filter-bar button { /* make it same size of top bar */
219
+ width: 17.6vw;
220
+ height: 2.3em;
221
+ }
222
.breadcrumb {
223
word-break: break-all; /* solves with very long names without spaces. 'break-word' is nicer but doesn't handle worst
224
cases like /gear/mininova/x/LOOPMASTERS%204Gig%20Pack/LOOPMASTERS_2015/BASS_HOUSE_AND_GARAGE_2_DEMOS/SOUNDS_AND_FX/
frontend/src/menu.ts
+29
-16
@@ -10,11 +10,9 @@ import { useNavigate } from 'react-router-dom'
10
import _ from 'lodash'
11
12
export function MenuPanel() {
13
- const { remoteSearch, stopSearch, stoppedSearch, listFilter, selected } = useSnapState()
14
- const [showFilter, setShowFilter] = useState(listFilter > '')
15
- const [filter, setFilter] = useState(listFilter)
16
- ;[state.listFilter] = useDebounce(showFilter ? filter : '', 300)
17
- state.showFilter = showFilter
13
+ const { showFilter, remoteSearch, stopSearch, stoppedSearch, patternFilter, selected } = useSnapState()
14
+ const [filter, setFilter] = useState(patternFilter)
15
+ ;[state.patternFilter] = useDebounce(showFilter ? filter : '', 300)
16
useEffect(() => {
17
if (!showFilter)
18
state.selected = {}
@@ -37,7 +35,7 @@ export function MenuPanel() {
35
label: 'Filter',
36
toggled: showFilter,
37
onClick() {
40
- setShowFilter(!showFilter)
38
+ state.showFilter = !showFilter
39
}
40
}),
41
h(MenuButton, getSearchProps()),
@@ -59,16 +57,31 @@ export function MenuPanel() {
57
),
58
remoteSearch && h('div', { id: 'searched' },
59
(stopSearch ? 'Searching' : 'Searched') + ': ' + remoteSearch + prefix(' (', stoppedSearch && 'interrupted', ')')),
62
- showFilter && h('input', {
63
- id: 'filter',
64
- placeholder: 'Filter',
65
- autocomplete: 'off',
66
- value: filter,
67
- autoFocus: true,
68
- onChange(ev) {
69
- setFilter(ev.target.value)
70
- }
71
- }),
60
+ showFilter && h('div', { id: 'filter-bar' },
61
+ h('input', {
62
+ id: 'filter',
63
+ placeholder: 'Filter',
64
+ autoComplete: 'off',
65
+ value: filter,
66
+ autoFocus: true,
67
+ onChange(ev) {
68
+ setFilter(ev.target.value)
69
+ }
70
+ }),
71
+ h(MenuButton, {
72
+ icon: 'invert',
73
+ label: 'Invert selection',
74
+ onClick() {
75
+ const sel = state.selected
76
+ for (const { hidden, n } of state.list)
77
+ if (!hidden)
78
+ if (sel[n])
79
+ delete sel[n]
80
+ else
81
+ sel[n] = true
82
+ }
83
+ })
84
+ )
85
)
86
87
frontend/src/misc.ts
+1
-1
@@ -11,7 +11,7 @@ export function hIcon(name: string, props?:any) {
11
return h(Icon, { name, ...props })
12
}
13
14
-export function hError(err?: Error | string) {
14
+export function hError(err: Error | string | null) {
15
return err && h('div', { className:'error-msg' }, typeof err === 'string' ? err : err.message)
16
}
17
frontend/src/state.ts
+11
-2
@@ -1,13 +1,18 @@
1
import _ from 'lodash'
2
import { proxy, useSnapshot } from 'valtio'
3
import { subscribeKey } from 'valtio/utils'
4
+import { DirList } from './BrowseFiles'
5
6
export const state = proxy<{
7
stopSearch?: ()=>void,
8
stoppedSearch?: boolean,
9
iconsClass: string,
10
username: string,
10
- listFilter: string,
11
+ list: DirList,
12
+ loading: boolean,
13
+ error: Error | null,
14
+ listReloader: number,
15
+ patternFilter: string,
16
showFilter: boolean,
17
selected: Record<string,true>, // optimization: by using an object instead of an array, components are not rendered when the array changes, but only when their specific property change
18
remoteSearch: string,
@@ -19,7 +24,11 @@ export const state = proxy<{
24
}>({
25
iconsClass: '',
26
username: '',
22
- listFilter: '',
27
+ list: [],
28
+ loading: false,
29
+ error: null,
30
+ listReloader: 0,
31
+ patternFilter: '',
32
showFilter: false,
33
selected: {},
34
remoteSearch: '',
frontend/src/useFetchList.ts
+36
-32
@@ -1,24 +1,15 @@
1
import { state, useSnapState } from './state'
2
-import { useEffect, useRef, useState } from 'react'
2
+import { useEffect, useRef } from 'react'
3
import { apiEvents } from './api'
4
import { DirEntry, DirList, usePath } from './BrowseFiles'
5
-import { useForceUpdate } from './misc'
5
+import _ from 'lodash'
6
+import { subscribeKey } from 'valtio/utils'
7
8
export default function useFetchList() {
9
const snap = useSnapState()
10
const desiredPath = usePath()
11
const search = snap.remoteSearch || undefined
11
- const [list, setList] = useState<DirList>([])
12
- const [loading, setLoading] = useState(false)
13
- const [error, setError] = useState<Error>()
12
const lastPath = useRef('')
15
- const [reload, forcer] = useForceUpdate()
16
-
17
- // reorder in case sort criteria change
18
- const { sortBy, invertOrder, foldersFirst } = snap
19
- useEffect(()=>{
20
- setList(sort(list))
21
- }, [sortBy, invertOrder, foldersFirst]) //eslint-disable-line
13
14
useEffect(()=>{
15
if (!desiredPath.endsWith('/')) { // useful only in dev, while accessing the frontend directly without passing by the main server
@@ -27,39 +18,39 @@ export default function useFetchList() {
18
}
19
const previous = lastPath.current
20
lastPath.current = desiredPath
30
- if (previous !== desiredPath)
21
+ if (previous !== desiredPath) {
22
+ state.showFilter = false
23
state.stopSearch?.()
24
+ }
25
state.stoppedSearch = false
26
if (previous !== desiredPath && search) {
27
state.remoteSearch = ''
35
- state.stopSearch?.()
28
return
29
}
30
31
const API = 'file_list'
32
const baseParams = { path:desiredPath, search, sse:true, omit:'c' }
41
- let list: DirList = []
42
- setList(list)
43
- setLoading(true)
33
+ state.list = []
34
+ state.selected = {}
35
+ state.loading = true
36
+ state.error = null
37
// buffering entries is necessary against burst of events that will hang the browser
45
-
46
- setError(undefined)
38
const buffer: DirList = []
39
const flush = () => {
40
const chunk = buffer.splice(0, Infinity)
41
if (chunk.length)
51
- setList(list = sort([...list, ...chunk.map(precalculate)]))
42
+ state.list = sort([...state.list, ...chunk.map(precalculate)])
43
}
44
const timer = setInterval(flush, 1000)
45
const src = apiEvents(API, baseParams, (type, data) => {
46
switch (type) {
47
case 'error':
48
state.stopSearch?.()
58
- return setError(Error(JSON.stringify(data)))
49
+ return state.error = Error(JSON.stringify(data))
50
case 'closed':
51
flush()
52
state.stopSearch?.()
62
- return setLoading(false)
53
+ return state.loading = false
54
case 'msg':
55
if (src?.readyState === src?.CLOSED)
56
return state.stopSearch?.()
@@ -69,19 +60,15 @@ export default function useFetchList() {
60
state.stopSearch = ()=>{
61
state.stopSearch = undefined
62
buffer.length = 0
72
- setLoading(false)
63
+ state.loading = false
64
clearInterval(timer)
65
src.close()
66
}
76
- }, [desiredPath, search, snap.username, forcer])
77
- return {
78
- list, loading, error,
79
- reload() {
80
- state.remoteSearch = ''
81
- state.stopSearch?.()
82
- reload()
83
- }
84
- }
67
+ }, [desiredPath, search, snap.username, snap.listReloader])
68
+}
69
+
70
+export function reloadList() {
71
+ state.listReloader = Date.now()
72
}
73
74
const { compare:localCompare } = new Intl.Collator(navigator.language)
@@ -118,3 +105,20 @@ function precalculate(rec:DirEntry) {
105
function compare(a:any, b:any) {
106
return a < b ? -1 : a > b ? 1 : 0
107
}
108
+
109
+// update list on sorting criteria
110
+const sortAgain = _.debounce(()=> state.list = sort(state.list), 100)
111
+subscribeKey(state, 'sortBy', sortAgain)
112
+subscribeKey(state, 'invertOrder', sortAgain)
113
+subscribeKey(state, 'foldersFirst', sortAgain)
114
+
115
+subscribeKey(state, 'patternFilter', v => {
116
+ const filter = v > '' && new RegExp(_.escapeRegExp(v),'i')
117
+ let n = 0
118
+ for (const entry of state.list) {
119
+ entry.hidden = filter && !filter.test(entry.n)
120
+ if (!entry.hidden)
121
+ ++n
122
+ }
123
+ state.filteredEntries = filter ? n : -1
124
+})
todo.md
-2
@@ -1,6 +1,4 @@
1
# To do
2
-- filter: add button to invert selection
3
-- filter: add button to select all
2
- log filter option
3
- log filter plugin
4
- publish to npm (so people can "npm install hfs")