sort list
Massimo Melina committed
Jan 3, 2022 at 11:24 UTC
51ac7c1b05faa4539dc3eb6f3afd3b7ea43c8412
13 files changed
+160
-27
frontend/package-lock.json
+4
-5
@@ -1,13 +1,14 @@
1
{
2
"name": "frontend",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
"lockfileVersion": 2,
5
"requires": true,
6
"packages": {
7
"": {
8
"name": "frontend",
9
- "version": "0.1.0",
9
+ "version": "0.3.0",
10
"dependencies": {
11
+ "lodash": "^4.17.21",
12
"react": "^17.0.2",
13
"react-dom": "^17.0.2",
14
"react-router-dom": "^6.1.1",
@@ -11491,7 +11492,6 @@
11492
},
11493
"node_modules/lodash": {
11494
"version": "4.17.21",
11494
- "dev": true,
11495
"license": "MIT"
11496
},
11497
"node_modules/lodash.debounce": {
@@ -27027,8 +27027,7 @@
27027
}
27028
},
27029
"lodash": {
27030
- "version": "4.17.21",
27031
- "dev": true
27030
+ "version": "4.17.21"
27031
},
27032
"lodash.debounce": {
27033
"version": "4.0.8",
frontend/package.json
+2
-1
@@ -16,7 +16,8 @@
16
"react-router-dom": "^6.1.1",
17
"use-debounce": "^7.0.1",
18
"valtio": "^1.2.7",
19
- "web-vitals": "^1.0.1"
19
+ "web-vitals": "^1.0.1",
20
+ "lodash": "^4.17.21"
21
},
22
"devDependencies": {
23
"@testing-library/jest-dom": "^5.11.4",
frontend/src/BrowseFiles.ts
+7
-8
@@ -11,7 +11,8 @@ export function usePath() {
11
return decodeURI(useLocation().pathname)
12
}
13
14
-interface DirEntry { n:string, s?:number, m?:string, c?:string }
14
+export interface DirEntry { n:string, s?:number, m?:string, c?:string,
15
+ ext:string, isFolder:boolean, t:Date } // we memoize these value for speed
16
export type DirList = DirEntry[]
17
interface ListRes { list:DirList, loading?:boolean, err?:Error, reload?:()=>void }
18
@@ -39,16 +40,14 @@ function FilesList() {
40
return ret
41
}
42
42
-function File({ n, m, c, s, hidden }: DirEntry & { hidden:boolean }) {
43
+function File({ n, t, s, hidden, isFolder }: DirEntry & { hidden:boolean }) {
44
const base = usePath()
44
- const isDir = n.endsWith('/')
45
- const t = m||c ||null
46
- const containerDir = isDir ? '' : n.substring(0, n.lastIndexOf('/')+1)
45
+ const containerDir = isFolder ? '' : n.substring(0, n.lastIndexOf('/')+1)
46
if (containerDir)
47
n = n.substring(containerDir.length)
48
const href = fix(containerDir + n)
50
- return h('li', { className:isDir ? 'folder' : 'file', style:hidden ? { display:'none' } : null },
51
- isDir ? h(Link, { to: base+href }, hIcon('folder'), n)
49
+ return h('li', { className:isFolder ? 'folder' : 'file', style:hidden ? { display:'none' } : null },
50
+ isFolder ? h(Link, { to: base+href }, hIcon('folder'), n)
51
: h(Fragment, {},
52
containerDir && h(Link, { to: base+fix(containerDir), className:'container-folder' }, hIcon('file'), containerDir ),
53
h('a', { href }, !containerDir && hIcon('file'), n)
@@ -58,7 +57,7 @@ function File({ n, m, c, s, hidden }: DirEntry & { hidden:boolean }) {
57
h('span', { className:'entry-size' }, formatBytes(s)),
58
hIcon('download'),
59
),
61
- t && h('span', { className:'entry-ts' }, new Date(t).toLocaleString()),
60
+ t && h('span', { className:'entry-ts' }, t.toLocaleString()),
61
),
62
h('div', { style:{ clear:'both' } })
63
)
frontend/src/Head.ts
+1
-1
@@ -20,7 +20,7 @@ function FolderStats() {
20
const stats = useMemo(() =>{
21
let files = 0, folders = 0, size = 0
22
for (const x of list) {
23
- if (x.n.endsWith('/'))
23
+ if (x.isFolder)
24
++folders
25
else
26
++files
frontend/src/components.ts
+28
@@ -1,6 +1,34 @@
1
import { hIcon } from './misc'
2
+import { createElement as h, ReactNode } from 'react'
3
4
export function Spinner() {
5
return hIcon('spinner', { className:'spinner' })
6
}
7
8
+export function Flex({ gap='1em', vert=false, children=null }) {
9
+ return h('div', {
10
+ style: {
11
+ display: 'flex',
12
+ gap,
13
+ flexDirection: vert ? 'column' : undefined,
14
+ }
15
+ }, children)
16
+}
17
+
18
+export function FlexV(props:any) {
19
+ return h(Flex, { vert:true, ...props })
20
+}
21
+
22
+interface CheckboxOptions { children?:ReactNode, value:any, onChange?:(v:boolean)=>void }
23
+export function Checkbox({ onChange, value, children, ...props }:CheckboxOptions) {
24
+ return h('label', {},
25
+ h('input',{
26
+ type:'checkbox',
27
+ onChange: ev => onChange?.(Boolean(ev.target.checked)),
28
+ checked: Boolean(value),
29
+ value: 1,
30
+ ...props
31
+ }),
32
+ children
33
+ )
34
+}
frontend/src/dialog.ts
+9
-5
@@ -3,7 +3,7 @@ import { proxy, useSnapshot } from 'valtio'
3
import './dialog.css'
4
5
interface DialogOptions {
6
- content: string | FunctionComponent,
6
+ Content: FunctionComponent,
7
closable?: boolean,
8
onClose?: (v?:any)=> any,
9
className?: string,
@@ -35,7 +35,7 @@ function Dialog(d:DialogOptions) {
35
h('div', { className:'dialog '+(d.className||'') },
36
d.closable || d.closable===undefined && h('button', { className:'dialog-icon dialog-closer', onClick:()=> closeDialog() }, d.closableContent),
37
d.icon && h('div', { className:'dialog-icon dialog-type' }, d.icon),
38
- h('div', {}, typeof d.content === 'function' ? h(d.content) : d.content)
38
+ h('div', {}, h(d.Content || 'div'))
39
))
40
}
41
@@ -74,7 +74,7 @@ export async function promptDialog(msg: string, { def, type }:PromptOptions={})
74
className: 'dialog-prompt',
75
icon: '?',
76
onClose: resolve,
77
- content: Content
77
+ Content
78
}) )
79
80
function Content() {
@@ -117,8 +117,12 @@ export async function alertDialog(msg: string | Error, type:AlertType='info') {
117
className: 'dialog-alert-'+type,
118
icon: '!',
119
onClose: resolve,
120
- content: String(msg),
120
+ Content
121
}))
122
+
123
+ function Content(){
124
+ return h('span', String(msg))
125
+ }
126
}
127
128
export async function confirmDialog(msg: string) : Promise<boolean> {
@@ -126,7 +130,7 @@ export async function confirmDialog(msg: string) : Promise<boolean> {
130
className: 'dialog-confirm',
131
icon: '?',
132
onClose: resolve,
129
- content: Content
133
+ Content
134
}) )
135
136
function Content() {
frontend/src/icons.ts
+1
@@ -9,6 +9,7 @@ const SYS_ICONS: Record<string,string> = {
9
spinner: 'sports_baseball',
10
filter: 'filter_alt',
11
interrupted: 'heart_broken',
12
+ sort: 'sort_by_alpha',
13
}
14
15
document.fonts.ready.then(async ()=> {
frontend/src/index.scss
+4
-1
@@ -13,11 +13,14 @@ body {
13
code {
14
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
15
}
16
-input {
16
+input, select {
17
padding: 0.3em 0.4em;
18
border-radius: 0.5em;
19
border-color: #0005;
20
}
21
+input[type=checkbox] {
22
+ vertical-align: text-bottom;
23
+}
24
25
.icon {
26
vertical-align: text-bottom;
frontend/src/menu.ts
+29
-1
@@ -5,6 +5,7 @@ import { alertDialog, closeDialog, newDialog, promptDialog } from './dialog'
5
import { hIcon, prefix } from './misc'
6
import { login, logout } from './login'
7
import { apiCall } from './api'
8
+import { Checkbox, FlexV } from './components'
9
10
export function MenuPanel() {
11
const { remoteSearch, stopSearch, stoppedSearch, listFilter } = useSnapState()
@@ -30,6 +31,33 @@ export function MenuPanel() {
31
}
32
}),
33
h(MenuButton, getSearchProps()),
34
+ h(MenuButton, {
35
+ icon: 'sort',
36
+ label: 'Sort',
37
+ onClick(){
38
+ const options = ['name','extension','size','time']
39
+ const close = newDialog({ Content })
40
+
41
+ function Content(){
42
+ const snap = useSnapState()
43
+ return h(FlexV, {},
44
+ h('div', {}, 'Sort by'),
45
+ options.map(x => h('button',{
46
+ key: x,
47
+ onClick(){
48
+ close(state.sortBy = x)
49
+ }
50
+ }, x, ' ', snap.sortBy===x && hIcon('check'))),
51
+ h(Checkbox, {
52
+ value: snap.foldersFirst,
53
+ onChange(v) {
54
+ state.foldersFirst = v
55
+ }
56
+ }, 'Folders first')
57
+ )
58
+ }
59
+ }
60
+ }),
61
h(MenuButton, {
62
icon: 'archive',
63
label: 'Archive',
@@ -96,7 +124,7 @@ function LoginButton() {
124
icon: 'user',
125
label: snap.username,
126
onClick() {
99
- newDialog({ content: UserPanel })
127
+ newDialog({ Content: UserPanel })
128
},
129
} : {
130
icon: 'login',
frontend/src/misc.ts
+1
-1
@@ -65,7 +65,7 @@ export function working() {
65
isWorking = true
66
return newDialog({
67
closable: false,
68
- content: Spinner,
68
+ Content: Spinner,
69
reserveClosing: true,
70
className: 'working',
71
onClose(){
frontend/src/state.ts
+33
@@ -1,4 +1,6 @@
1
+import _ from 'lodash'
2
import { proxy, useSnapshot } from 'valtio'
3
+import { subscribeKey } from 'valtio/utils'
4
5
export const state = proxy<{
6
stopSearch?: ()=>void,
@@ -8,14 +10,45 @@ export const state = proxy<{
10
listFilter: string,
11
remoteSearch: string,
12
filteredEntries: number,
13
+ sortBy: string,
14
+ foldersFirst: boolean,
15
}>({
16
iconsClass: '',
17
username: '',
18
listFilter: '',
19
remoteSearch: '',
20
filteredEntries: -1,
21
+ sortBy: 'name',
22
+ foldersFirst: true,
23
})
24
25
export function useSnapState() {
26
return useSnapshot(state)
27
}
28
+
29
+const SETTINGS_KEY = 'hfs_settings'
30
+const SETTINGS_TO_STORE: (keyof typeof state)[] = ['sortBy','foldersFirst']
31
+
32
+loadSettings()
33
+for (const k of SETTINGS_TO_STORE)
34
+ subscribeKey(state, k, storeSettings)
35
+
36
+function loadSettings() {
37
+ const json = localStorage.getItem(SETTINGS_KEY)
38
+ if (!json) return
39
+ let read
40
+ try { read = JSON.parse(json) }
41
+ catch(e) {
42
+ console.error('invalid settings stored', json)
43
+ return
44
+ }
45
+ for (const k of SETTINGS_TO_STORE) {
46
+ const v = read[k]
47
+ if (v !== undefined) // @ts-ignore
48
+ state[k] = v
49
+ }
50
+}
51
+
52
+function storeSettings() {
53
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(_.pick(state, SETTINGS_TO_STORE)))
54
+}
frontend/src/useFetchList.ts
+41
-2
@@ -1,7 +1,7 @@
1
import { state, useSnapState } from './state'
2
import { useEffect, useRef, useState } from 'react'
3
import { apiEvents } from './api'
4
-import { DirList, usePath } from './BrowseFiles'
4
+import { DirEntry, DirList, usePath } from './BrowseFiles'
5
import { useForceUpdate } from './misc'
6
7
export default function useFetchList() {
@@ -13,6 +13,13 @@ export default function useFetchList() {
13
const [error, setError] = useState<Error>()
14
const lastPath = useRef('')
15
const [reload, forcer] = useForceUpdate()
16
+
17
+ // reorder in case sort criteria change
18
+ const { sortBy, foldersFirst } = snap
19
+ useEffect(()=>{
20
+ setList(sort(list))
21
+ }, [sortBy, foldersFirst])
22
+
23
useEffect(()=>{
24
const loc = window.location
25
if (!desiredPath.endsWith('/')) { // useful only in dev, while accessing the frontend directly without passing by the main server
@@ -43,7 +50,7 @@ export default function useFetchList() {
50
const flush = () => {
51
const chunk = buffer.splice(0, Infinity)
52
if (chunk.length)
46
- setList(list = [...list, ...chunk])
53
+ setList(list = sort([...list, ...chunk.map(precalculate)]))
54
}
55
const timer = setInterval(flush, 1000)
56
const src = apiEvents(API, baseParams, (type, data) => {
@@ -81,3 +88,35 @@ export default function useFetchList() {
88
}
89
}
90
91
+const { compare:localCompare } = new Intl.Collator(navigator.language)
92
+
93
+function sort(list: DirList) {
94
+ const { sortBy, foldersFirst } = state
95
+ // optimization: precalculate string comparisons
96
+ const bySize = sortBy === 'size'
97
+ const byExt = sortBy === 'extension'
98
+ const byTime = sortBy === 'time'
99
+ return list.sort((a,b) =>
100
+ foldersFirst && -compare(a.isFolder, b.isFolder)
101
+ || (bySize ? compare(a.s||0, b.s||0)
102
+ : byExt ? localCompare(a.ext, b.ext)
103
+ : byTime ? compare(a.t, b.t)
104
+ : 0
105
+ )
106
+ || localCompare(a.n, b.n) // fallback to name/path
107
+ )
108
+}
109
+
110
+function precalculate(rec:DirEntry) {
111
+ const i = rec.n.lastIndexOf('.') + 1
112
+ rec.ext = i ? rec.n.substring(i) : ''
113
+ rec.isFolder = rec.n.endsWith('/')
114
+ const t = rec.m || rec.c
115
+ rec.t = new Date(t||0)
116
+ return rec
117
+}
118
+
119
+// generic comparison
120
+function compare(a:any, b:any) {
121
+ return a < b ? -1 : a > b ? 1 : 0
122
+}
todo.md
-2
@@ -1,7 +1,5 @@
1
# To do
2
- anti-csrf
3
-- file sorting
4
-- folders before?
3
- upload
4
- search and login dialogs should push to history so that mobile can use back button to close them
5
- get config values from command line