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")