@samitouri / QOSami-HFS / commits / abf4fb8c

alphabetical index

Massimo Melina committed May 10, 2026 at 00:03 UTC abf4fb8c0381b2da1f75f4f6d4b32cfb18a2f14e
4 files changed +199 -67
frontend/src/BrowseFiles.ts
+17 -66
@@ -21,6 +21,7 @@ import { ClipBar } from './clip'
21 import { fileShow, getShowComponent } from './show'
22 import i18n from './i18n'
23 import { dragFilesSource } from './dragFiles'
24 +import { PAGE_SEPARATOR_CLASS, Paging, scrollIntoView } from './Paging'
25 const { t, useI18N } = i18n
26
27 export const MISSING_PERM = "Missing permission"
@@ -66,24 +67,24 @@ function FilesList() {
67 const snap = useSnapState()
68 const midnight = useMidnight() // as an optimization, we calculate this only once per list and pass it down
69 const pageSize = Math.max(1, Math.floor(snap.page_size ?? 100))
69 - const [page, setPage] = useState(0)
70 + const [offset, setOffset] = useState(0)
71 const [extraPages, setExtraPages] = useState(0)
72 const [scrolledPages, setScrolledPages] = useState(0)
73 const [atBottom, setAtBottom] = useState(false)
73 - const offset = page * pageSize
74 const theList = snap.filteredList || snap.list
75 const total = theList.length
76 const nPages = Math.ceil(total / pageSize)
77 + const page = Math.floor(offset / pageSize)
78 const pageEnd = offset + pageSize * (1+extraPages) - 1
79 const thisPage = theList.slice(offset, pageEnd + 1)
80
80 - useEffect(() => setPage(0), [theList[0]]) // reset page if the list changes
81 + useEffect(() => setOffset(0), [theList[0]]) // reset page if the list changes
82 // reset scrolling if the page changes
83 useEffect(() => {
84 document.scrollingElement?.scrollTo(0, 0)
85 setExtraPages(0)
86 setScrolledPages(0)
86 - }, [page])
87 + }, [offset])
88
89 // continuous-scrolling
90 const calcScrolledPages = useMemo(() =>
@@ -94,7 +95,7 @@ function FilesList() {
95 setAtBottom(window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight)
96 }, 200),
97 [])
97 - const canAddPage = page + extraPages < nPages - 1
98 + const canAddPage = pageEnd < total - 1
99 useEffect(() => domOn('scroll', () => {
100 if (!theList.length) return
101 const timeToAdd = window.innerHeight * 1.3 + window.scrollY >= document.body.offsetHeight // 30vh before the end
@@ -168,7 +169,7 @@ function FilesList() {
169 if (offset && (ret < 0 || ret > pageEnd))
170 ret = search(0) // search again on the whole list
171 if (ret >= 0)
171 - setPage(Math.floor(ret / pageSize))
172 + setOffset(Math.floor(ret / pageSize) * pageSize)
173 return ret
174
175 function search(offset: number) {
@@ -209,12 +210,17 @@ function FilesList() {
210 if (pleaseGoBottom)
211 setGoBottom(true)
212 if (i < page || i > page + extraPages)
212 - return setPage(i)
213 + return setOffset(i * pageSize)
214 i -= page + 1
215 const el = i < 0 ? ref.current?.querySelector('*')
216 : document.querySelectorAll('.' + PAGE_SEPARATOR_CLASS)[i]
217 scrollIntoView(el, 'center')
217 - }, [page, extraPages])
218 + }, [page, extraPages, pageSize])
219 + const changePageToIndex = useCallback((i: number) => {
220 + setFocus('')
221 + // alphabetical paging is entry-anchored, so the chosen group starts at the top instead of at the numeric page boundary
222 + setOffset(i)
223 + }, [])
224
225 const {t} = useI18N()
226
@@ -242,67 +248,14 @@ function FilesList() {
248 current: page + scrolledPages,
249 atBottom,
250 pageSize,
251 + list: theList as DirEntry[],
252 + showAlphabet: snap.sort_by === 'name' && !snap.invert_order,
253 changePage,
254 + changePageToIndex,
255 })
256 )
257 }
258
250 -interface PagingProps {
251 - nPages: number
252 - current: number
253 - atBottom: boolean
254 - pageSize: number
255 - changePage: (newPage:number, goBottom?:boolean) => void
256 -}
257 -const Paging = memo(({ nPages, current, pageSize, changePage, atBottom }: PagingProps) => {
258 - useEffect(() => {
259 - document.body.style.overflowY = 'scroll'
260 - return () => { document.body.style.overflowY = '' }
261 - }, [])
262 - const lastScrollTimeRef = useRef(0)
263 - useEffect(() => domOn('scroll', () => lastScrollTimeRef.current = Date.now()), [])
264 - const ref = useRef<HTMLElement>()
265 - useEffect(() => { // in case the page changed using the continuous-scrolling, we want to re-center, but only if it happened for a user interaction different from the scrolling
266 - if (Date.now() - lastScrollTimeRef.current > 500)
267 - scrollIntoView(ref.current, 'nearest')
268 - }, [current])
269 - const shrink = nPages > 20
270 - const from = _.floor(current, -1)
271 - const to = from + 10
272 - return h('div', { id: 'paging' },
273 - h('button', {
274 - title: t('go_first', "Go to first item"),
275 - className: !current ? 'toggled' : undefined,
276 - onClick() { changePage(0) },
277 - }, hIcon('to_start')),
278 - h('div', { id: 'paging-middle' }, // using sticky first/last would prevent scrollIntoView from working
279 - _.range(1, nPages).map(i => {
280 - if (shrink && i % 10 && (i < from || i >= to))
281 - return false
282 - const pageStart = i * pageSize
283 - return h('button', {
284 - key: i,
285 - ...i === current && { className: 'toggled', ref },
286 - onClick: () => changePage(i),
287 - }, shrink && !(i % 10) && pageStart >= 1000 ? (pageStart / 1000) + 'K' : pageStart)
288 - })
289 - ),
290 - h('button', {
291 - title: t('go_last', "Go to last item"),
292 - className: atBottom ? 'toggled' : undefined,
293 - onClick(){ changePage(nPages-1, true) }
294 - }, hIcon('to_end')),
295 - )
296 -})
297 -
298 -function scrollIntoView(el: Element | undefined | null, block: ScrollLogicalPosition) {
299 - if (!el) return
300 - try { el.scrollIntoView({ block }) }
301 - catch { // firefox 52 rejects modern scrollIntoView options, so we fall back to the legacy boolean signature
302 - el.scrollIntoView(block === 'center')
303 - }
304 -}
305 -
259 export function useMidnight() {
260 const [midnight, setMidnight] = useState(calcMidnight)
261 useEffect(() => {
@@ -319,8 +272,6 @@ export function useMidnight() {
272 }
273 }
274
322 -const PAGE_SEPARATOR_CLASS = 'page-separator'
323 -
275 interface EntryProps { entry: DirEntry, midnight: Date, separator?: string }
276 const Entry = ({ entry, midnight, separator }: EntryProps) => {
277 const { uri, isFolder, name, n } = entry
frontend/src/Paging.ts new
+150
@@ -0,0 +1,150 @@
1 +// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2 +
3 +import { createElement as h, memo, useEffect, useMemo, useRef, useState } from 'react'
4 +import _ from 'lodash'
5 +import { DirEntry } from './state'
6 +import { domOn, getHFS, hIcon } from './misc'
7 +import i18n from './i18n'
8 +const { t } = i18n
9 +
10 +export const PAGE_SEPARATOR_CLASS = 'page-separator'
11 +
12 +interface PagingProps {
13 + nPages: number
14 + current: number
15 + atBottom: boolean
16 + pageSize: number
17 + list: DirEntry[]
18 + showAlphabet: boolean
19 + changePage: (newPage:number, goBottom?:boolean) => void
20 + changePageToIndex: (entryIndex: number) => void
21 +}
22 +
23 +interface AlphabetGroup {
24 + label: string
25 + index: number
26 +}
27 +
28 +export const Paging = memo(({ nPages, current, pageSize, list, showAlphabet, changePage, changePageToIndex, atBottom }: PagingProps) => {
29 + const [alphabetOpen, setAlphabetOpen] = useState(false)
30 + useEffect(() => {
31 + document.body.style.overflowY = 'scroll'
32 + return () => { document.body.style.overflowY = '' }
33 + }, [])
34 + const lastScrollTimeRef = useRef(0)
35 + useEffect(() => domOn('scroll', () => lastScrollTimeRef.current = Date.now()), [])
36 + const ref = useRef<HTMLElement>()
37 + useEffect(() => { // in case the page changed using the continuous-scrolling, we want to re-center, but only if it happened for a user interaction different from the scrolling
38 + if (Date.now() - lastScrollTimeRef.current > 500)
39 + scrollIntoView(ref.current, 'nearest')
40 + }, [current])
41 + const shrink = nPages > 20
42 + const from = _.floor(current, -1)
43 + const to = from + 10
44 + const alphabetGroups = useMemo(() => showAlphabet ? getAlphabetGroups(list) : [], [list, showAlphabet])
45 + useEffect(() => {
46 + if (!alphabetGroups.length)
47 + setAlphabetOpen(false)
48 + }, [alphabetGroups.length])
49 + return h('div', { id: 'paging' },
50 + h('button', {
51 + title: t('go_first', "Go to first item"),
52 + className: !current ? 'toggled' : undefined,
53 + onClick() { changePage(0) },
54 + }, hIcon('to_start')),
55 + h('div', { id: 'paging-middle' }, // using sticky first/last would prevent scrollIntoView from working
56 + _.range(1, nPages).map(i => {
57 + if (shrink && i % 10 && (i < from || i >= to))
58 + return false
59 + const pageStart = i * pageSize
60 + return h('button', {
61 + key: i,
62 + ...i === current && { className: 'toggled', ref },
63 + onClick: () => changePage(i),
64 + }, shrink && !(i % 10) && pageStart >= 1000 ? (pageStart / 1000) + 'K' : pageStart)
65 + })
66 + ),
67 + h('button', {
68 + title: t('go_last', "Go to last item"),
69 + className: atBottom ? 'toggled' : undefined,
70 + onClick(){ changePage(nPages-1, true) }
71 + }, hIcon('to_end')),
72 + Boolean(alphabetGroups.length) && h(AlphabetPaging, {
73 + groups: alphabetGroups,
74 + open: alphabetOpen,
75 + toggleOpen: () => setAlphabetOpen(x => !x),
76 + close: () => setAlphabetOpen(false),
77 + changePage: i => {
78 + setAlphabetOpen(false)
79 + changePageToIndex(i)
80 + },
81 + }),
82 + )
83 +})
84 +
85 +interface AlphabetPagingProps {
86 + groups: AlphabetGroup[]
87 + open: boolean
88 + toggleOpen: () => void
89 + close: () => void
90 + changePage: (entryIndex: number) => void
91 +}
92 +
93 +function AlphabetPaging({ groups, open, toggleOpen, close, changePage }: AlphabetPagingProps) {
94 + const ref = useRef<HTMLElement>()
95 + useEffect(() => {
96 + if (!open) return
97 + return domOn('pointerdown', ev => {
98 + const el = ref.current
99 + // the outside listener is global, so clicks inside the popup must be ignored here
100 + if (el && ev.target instanceof Node && !el.contains(ev.target))
101 + close()
102 + })
103 + }, [open, close])
104 + return h('div', { ref, id: 'alphabet-paging', className: open ? 'open' : undefined },
105 + open && h('div', { id: 'alphabet-paging-bar' },
106 + groups.map(({ label, index }) =>
107 + h('button', {
108 + key: label,
109 + onClick: () => changePage(index),
110 + }, label))
111 + ),
112 + h('button', {
113 + id: 'alphabet-paging-toggle',
114 + title: t('alpha_idx', "Alphabetical index"),
115 + onClick: toggleOpen,
116 + }, t('alpha_idx_button', "AZ"))
117 + )
118 +}
119 +
120 +function getAlphabetGroups(list: DirEntry[]) {
121 + const groups: AlphabetGroup[] = []
122 + const seen = new Set<string>()
123 + list.forEach((entry, index) => {
124 + const label = getAlphabetGroup(entry.name)
125 + if (!label) return
126 + if (seen.has(label)) return
127 + seen.add(label)
128 + groups.push({ label, index })
129 + })
130 + // the list order can include non-letter entries and custom filename collation; the index stays tied to the first real entry
131 + return groups.length > 1 ? groups.sort((a, b) => getHFS().textSortCompare(a.label, b.label)) : []
132 +}
133 +
134 +function getAlphabetGroup(name: string) {
135 + const first = Array.from(name.trim())[0] || ''
136 + if (!first) return ''
137 + const latin = first.normalize('NFD').replace(/\p{Diacritic}/gu, '').toUpperCase()
138 + if (/^[A-Z]$/.test(latin))
139 + return latin
140 + const upper = first.toLocaleUpperCase()
141 + return /\p{Letter}/u.test(upper) ? upper : ''
142 +}
143 +
144 +export function scrollIntoView(el: Element | undefined | null, block: ScrollLogicalPosition) {
145 + if (!el) return
146 + try { el.scrollIntoView({ block }) }
147 + catch { // firefox 52 rejects modern scrollIntoView options, so we fall back to the legacy boolean signature
148 + el.scrollIntoView(block === 'center')
149 + }
150 +}
frontend/src/index.scss
+30 -1
@@ -457,7 +457,7 @@ button .icon + .label {
457 position: sticky;
458 bottom: 0;
459 display: flex;
460 - gap: .1em;
460 + gap: .3em;
461 background-color: var(--bg);
462 padding: 0 0.2em 0.2em;
463 &>button { z-index: 1; }
@@ -468,6 +468,7 @@ button .icon + .label {
468 display: flex;
469 gap: .5em;
470 flex: 1;
471 + min-width: 0;
472 overflow-x: auto;
473 }
474 #paging-middle>button {
@@ -481,6 +482,34 @@ button .icon + .label {
482 white-space: nowrap;
483 padding: .5em; /* fit more buttons on screen */
484 }
485 + #alphabet-paging {
486 + position: relative;
487 + z-index: 2;
488 + display: flex;
489 + margin-left: .2em;
490 + &.open #alphabet-paging-toggle {
491 + min-width: 2.4em;
492 + }
493 + }
494 + #alphabet-paging-toggle {
495 + min-width: 2.8em;
496 + }
497 + #alphabet-paging-bar {
498 + position: absolute;
499 + right: 100%;
500 + bottom: 0;
501 + display: flex;
502 + gap: .2em;
503 + max-width: calc(100vw - 5.5em);
504 + overflow-x: auto;
505 + padding: .2em .2em .2em .5em;
506 + background: var(--bg);
507 + box-shadow: -.2em 0 .3em .2em var(--bg);
508 + button {
509 + min-width: 2.2em;
510 + padding: .5em .6em;
511 + }
512 + }
513 }
514
515 .upload-toolbar {
src/langs/hfs-lang-en.json
+2
@@ -33,6 +33,8 @@
33 "stopped_before": "Stopped before finding anything",
34 "empty_list": "Nothing here",
35 "filter_none": "No match for this filter",
36 + "alpha_idx": "Alphabetical index",
37 + "alpha_idx_button": "AZ",
38
39 "Admin-panel": "Admin-panel",
40 "Login": "Login",