paginated files list

Massimo Melina committed Jan 23, 2022 at 10:57 UTC ef5203333ea48509b2017538e2ddadfd72628839
6 files changed +102 -29
frontend/src/BrowseFiles.ts
+42 -10
@@ -12,7 +12,7 @@ export function usePath() {
12 }
13
14 export interface DirEntry { n:string, s?:number, m?:string, c?:string,
15 - ext:string, isFolder:boolean, t?:Date, hidden?:boolean } // we memoize these value for speed
15 + ext:string, isFolder:boolean, t?:Date } // we memoize these value for speed
16 export type DirList = DirEntry[]
17
18 export function BrowseFiles() {
@@ -25,14 +25,46 @@ export function BrowseFiles() {
25 }
26
27 function FilesList() {
28 - const snap = useSnapState()
29 - const { list, loading } = snap
28 + const { filteredList, list, loading, stoppedSearch } = useSnapState()
29 const midnight = useMidnight() // as an optimization we calculate this only once per list
31 - return h('ul', { className: 'dir' },
32 - !list.length ? (!loading && (snap.stoppedSearch ? 'Stopped before finding anything' : 'Nothing here'))
33 - : list.map((entry: DirEntry) =>
34 - h(Entry, { key: entry.n, midnight, ...entry })),
35 - loading && h(Spinner))
30 + const pageSize = 100
31 + const [page, setPage] = useState(0)
32 + const offset = page * pageSize
33 + const theList = filteredList || list
34 + const total = theList.length
35 +
36 + useEffect(() => setPage(0), [theList])
37 + useEffect(() => document.scrollingElement?.scrollTo(0,0), [page])
38 +
39 + return h(Fragment, {},
40 + h('ul', { className: 'dir' },
41 + !list.length ? (!loading && (stoppedSearch ? 'Stopped before finding anything' : 'Nothing here'))
42 + : filteredList && !filteredList.length ? 'No match for this filter'
43 + : theList.slice(offset, offset + pageSize).map((entry: DirEntry) =>
44 + h(Entry, { key: entry.n, midnight, ...entry })),
45 + loading && h(Spinner),
46 + ),
47 + total > pageSize && h(Paging, { total, current:page, pageSize, pageChange:setPage })
48 + )
49 +}
50 +
51 +interface PagingProps {
52 + total: number
53 + current: number
54 + pageSize: number
55 + pageChange:(newPage:number) => void
56 +}
57 +function Paging({ total, current, pageSize, pageChange }: PagingProps) {
58 + const nPages = Math.ceil(total / pageSize)
59 + const pages = []
60 + for (let i=0; i<nPages; i++)
61 + pages.push(h('button', {
62 + ...i===current && { className:'toggled' },
63 + onClick(){
64 + pageChange(i)
65 + }
66 + }, i*pageSize || 1))
67 + return h('div', { id:'paging' }, ...pages)
68 }
69
70 function useMidnight() {
@@ -56,13 +88,13 @@ function isMobile() {
88 }
89
90 const Entry = memo(function(entry: DirEntry & { midnight: Date }) {
59 - let { n: relativePath, hidden, isFolder } = entry
91 + let { n: relativePath, isFolder } = entry
92 const base = usePath()
93 const { showFilter, selected } = useSnapState()
94 const href = fixUrl(relativePath)
95 const containerDir = isFolder ? '' : relativePath.substring(0, relativePath.lastIndexOf('/')+1)
96 const name = relativePath.substring(containerDir.length)
65 - return h('li', { className: (isFolder ? 'folder' : 'file') + (hidden ? ' hidden' : '') },
97 + return h('li', { className: isFolder ? 'folder' : 'file' },
98 showFilter && h(Checkbox, {
99 value: selected[relativePath],
100 onChange(v){
frontend/src/Head.ts
+4 -3
@@ -15,7 +15,7 @@ export function Head() {
15 }
16
17 function FolderStats() {
18 - const { list, loading, filteredEntries, selected, stoppedSearch } = useSnapState()
18 + const { list, loading, filteredList, selected, stoppedSearch } = useSnapState()
19 const stats = useMemo(() =>{
20 let files = 0, folders = 0, size = 0
21 for (const x of list) {
@@ -28,15 +28,16 @@ function FolderStats() {
28 return { files, folders, size }
29 }, [list])
30 const sel = Object.keys(selected).length
31 + const fil = filteredList?.length
32 return h('div', { id:'folder-stats' },
33 stoppedSearch ? hIcon('interrupted', { title:'Search was interrupted' })
33 - : list?.length>0 && loading && h(Spinner),
34 + : list.length>0 && loading && h(Spinner),
35 [
36 prefix('', stats.files,' file(s)'),
37 prefix('', stats.folders, ' folder(s)'),
38 stats.size ? formatBytes(stats.size) : '',
39 sel && sel+' selected',
39 - filteredEntries >= 0 && filteredEntries+' displayed',
40 + fil !== undefined && fil < list.length && fil+' displayed',
41 ].filter(Boolean).join(', ')
42 )
43 }
frontend/src/index.scss
+40
@@ -1,12 +1,15 @@
1 :root {
2 --bg: #fff;
3 --text: #555;
4 + --faint-contrast: #0002;
5 --mild-contrast: #0005;
6 + --good-contrast: #000a;
7 --button-bg: #a4bac9;
8 --button-text: #444;
9 .theme-dark {
10 --bg: #000;
11 --text: #999;
12 + --faint-contrast: #fff2;
13 --mild-contrast: #fff5;
14 --good-contrast: #fffa;
15 --button-bg: #345;
@@ -31,6 +34,9 @@ body, button, select, input { font-size: 12pt; }
34 #root {
35 max-width: 50em;
36 margin: auto;
37 + min-height: 100vh;
38 + display: flex;
39 + flex-direction: column;
40 }
41 body, input {
42 color: var(--text);
@@ -144,6 +150,7 @@ header input {
150 }
151
152 ul.dir {
153 + flex: 1;
154 padding: 0;
155 margin: 0;
156 clear: both;
@@ -206,6 +213,39 @@ button label {
213 animation: 1s fade-in;
214 }
215
216 +#paging {
217 + display: flex;
218 + position: sticky;
219 + bottom: 0;
220 + background: var(--bg);
221 + gap: .5em;
222 + overflow-x: auto;
223 + &>button {
224 + flex: 1;
225 + background: var(--button-bg);
226 + text-align: center;
227 + }
228 +}
229 +
230 +/* Works on Firefox */
231 +* {
232 + scrollbar-width: thin;
233 + scrollbar-color: var(--button-bg) var(--faint-contrast);
234 +}
235 +
236 +/* Works on Chrome, Edge, and Safari */
237 +*::-webkit-scrollbar {
238 + width: 12px;
239 +}
240 +*::-webkit-scrollbar-track {
241 + background: var(--faint-contrast);
242 +}
243 +*::-webkit-scrollbar-thumb {
244 + background-color: var(--button-bg);
245 + border-radius: 20px;
246 + border: 1px solid var(--faint-contrast)
247 +}
248 +
249 @media (max-width: 40em) {
250 body, button, select { font-size: 14pt; }
251 #menu-bar {
frontend/src/menu.ts
+5 -6
@@ -73,12 +73,11 @@ export function MenuPanel() {
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
76 + for (const { n } of state.list)
77 + if (sel[n])
78 + delete sel[n]
79 + else
80 + sel[n] = true
81 }
82 })
83 )
frontend/src/state.ts
+2 -2
@@ -9,6 +9,7 @@ export const state = proxy<{
9 iconsClass: string,
10 username: string,
11 list: DirList,
12 + filteredList?: DirList,
13 loading: boolean,
14 error: Error | null,
15 listReloader: number,
@@ -16,7 +17,6 @@ export const state = proxy<{
17 showFilter: boolean,
18 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
19 remoteSearch: string,
19 - filteredEntries: number,
20 sortBy: string,
21 invertOrder: boolean,
22 foldersFirst: boolean,
@@ -25,6 +25,7 @@ export const state = proxy<{
25 iconsClass: '',
26 username: '',
27 list: [],
28 + filteredList: undefined,
29 loading: false,
30 error: null,
31 listReloader: 0,
@@ -32,7 +33,6 @@ export const state = proxy<{
33 showFilter: false,
34 selected: {},
35 remoteSearch: '',
35 - filteredEntries: -1,
36 sortBy: 'name',
37 invertOrder: false,
38 foldersFirst: true,
frontend/src/useFetchList.ts
+9 -8
@@ -31,6 +31,7 @@ export default function useFetchList() {
31 const API = 'file_list'
32 const baseParams = { path:desiredPath, search, sse:true, omit:'c' }
33 state.list = []
34 + state.filteredList = undefined
35 state.selected = {}
36 state.loading = true
37 state.error = null
@@ -113,12 +114,12 @@ subscribeKey(state, 'invertOrder', sortAgain)
114 subscribeKey(state, 'foldersFirst', sortAgain)
115
116 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
117 + if (!v)
118 + return state.filteredList = undefined
119 + const filter = new RegExp(_.escapeRegExp(v),'i')
120 + const newList = []
121 + for (const entry of state.list)
122 + if (filter.test(entry.n))
123 + newList.push(entry)
124 + state.filteredList = newList
125 })