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