selection checkboxes are now disabled on entries that cannot be archived or deleted
Massimo Melina committed
Oct 2, 2024 at 23:15 UTC
a33ca00631858fdfe252a0ac396235206b6e1162
8 files changed
+113
-95
README.md
+1
-1
@@ -123,7 +123,7 @@ If your language is missing, please consider [translating yourself](https://gith
123
124
- Appending `#LOGIN` to address will bring up the login dialog
125
- Appending ?lang=CODE to address will force a specific language
126
-- Right/ctrl/command click on toggle-all checkbox will invert each checkbox state
126
+- Right-click on toggle-all checkbox will invert each checkbox state
127
- Appending `?login=USER:PASSWORD` will automatically log in the browser
128
- Appending `?overwrite` on uploads, will override the dont_overwrite_uploading configuration, provided you also have delete permission
129
- Appending `?search=PATTERN` will trigger search at start
dev-plugins.md
+83
-73
@@ -312,91 +312,101 @@ You can produce output for such events also by adding sections (with same name a
312
This is a list of available frontend-events, with respective object parameter and output.
313
314
- `additionalEntryDetails`
315
- - you receive each entry of the list, and optionally produce HTML code that will be added in the `entry-details` container.
316
- - parameter `{ entry: DirEntry }`
317
-
318
- The `DirEntry` type is an object with the following properties:
319
- - `name: string` name of the entry.
320
- - `ext: string` just the extension part of the name, dot excluded and lowercase.
321
- - `isFolder: boolean` true if it's a folder.
322
- - `n: string` name of the entry, including relative path when searched in sub-folders.
323
- - `uri: string` relative url of the entry.
324
- - `s?: number` size of the entry, in bytes. It may be missing, for example for folders.
325
- - `t?: Date` generic timestamp, combination of creation-time and modified-time.
326
- - `c?: Date` creation-time.
327
- - `m?: Date` modified-time.
328
- - `p?: string` permissions missing
329
- - `cantOpen: boolean` true if current user has no permission to open this entry
330
- - `getNext/getPrevious: ()=>DirEntry` return next/previous DirEntry in list
331
- - `getNextFiltered/getPreviousFiltered: ()=>DirEntry` as above, but considers the filtered-list instead
332
- - `getDefaultIcon: ()=>ReactElement` produces the default icon for this entry
333
- - output `Html`
315
+ - you receive each entry of the list, and optionally produce HTML code that will be added in the `entry-details` container.
316
+ - parameter `{ entry: DirEntry }`
317
+
318
+ The `DirEntry` type is an object with the following properties:
319
+ - `name: string` name of the entry.
320
+ - `ext: string` just the extension part of the name, dot excluded and lowercase.
321
+ - `isFolder: boolean` true if it's a folder.
322
+ - `n: string` name of the entry, including relative path when searched in sub-folders.
323
+ - `uri: string` relative url of the entry.
324
+ - `s?: number` size of the entry, in bytes. It may be missing, for example for folders.
325
+ - `t?: Date` generic timestamp, combination of creation-time and modified-time.
326
+ - `c?: Date` creation-time.
327
+ - `m?: Date` modified-time.
328
+ - `p?: string` permissions missing
329
+ - `cantOpen: boolean` true if current user has no permission to open this entry
330
+ - `getNext/getPrevious: ()=>DirEntry` return next/previous DirEntry in list
331
+ - `getNextFiltered/getPreviousFiltered: ()=>DirEntry` as above, but considers the filtered-list instead
332
+ - `getDefaultIcon: ()=>ReactElement` produces the default icon for this entry
333
+ - output `Html`
334
- `entry`
335
- - you receive each entry of the list, and optionally produce HTML code that will completely replace the entry row/slot.
336
- - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
337
- - output `Html | null` return null if you want to hide this entry
335
+ - you receive each entry of the list, and optionally produce HTML code that will completely replace the entry row/slot.
336
+ - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
337
+ - output `Html | null` return null if you want to hide this entry
338
- `afterEntryName`
339
- - you receive each entry of the list, and optionally produce HTML code that will be added after the name of the entry.
340
- - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
341
- - output `Html`
339
+ - you receive each entry of the list, and optionally produce HTML code that will be added after the name of the entry.
340
+ - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
341
+ - output `Html`
342
- `entryIcon`
343
- - you receive an entry of the list and optionally produce HTML that will be used in place of the standard icon.
344
- - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
345
- - output `Html`
343
+ - you receive an entry of the list and optionally produce HTML that will be used in place of the standard icon.
344
+ - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
345
+ - output `Html`
346
- `beforeHeader` & `afterHeader`
347
- - use this to produce content that should go right before/after the `header` part
348
- - output `Html`
347
+ - use this to produce content that should go right before/after the `header` part
348
+ - output `Html`
349
- `beforeLogin`
350
- - no parameter
351
- - output `Html`
350
+ - no parameter
351
+ - output `Html`
352
- `fileMenu`
353
- - add or manipulate entries of the menu. If you return something, that will be added to the menu.
354
- You can also delete or replace the content of the `menu` array.
355
- - parameter `{ entry: DirEntry, menu: FileMenuEntry[], props: FileMenuProp[] }`
356
- - output `undefined | FileMenuEntry | FileMenuEntry[]`
357
- ```typescript
358
- interface FileMenuEntry {
359
- id?: string,
360
- label: ReactNode,
361
- subLabel: ReactNode,
362
- href?: string, // use this if you want your entry to be a link
363
- icon?: string, // supports: emoji, name from a limited set
364
- onClick?: () => (Promisable<boolean>) // return false to not close menu dialog
365
- //...rest is transfered to <a> element, for example 'target', or 'title'
366
- }
367
- type FileMenuProp = { id?: string, label: ReactNode, value: ReactNode } | ReactElement
368
- ```
369
- Example, if you want to remove the 'show' item of the menu:
370
- ```typescript
371
- HFS.onEvent('fileMenu', ({ entry, menu }) => {
372
- const index = menu.findIndex(x => x.id === 'show')
373
- if (index >= 0)
374
- menu.splice(index, 1)
375
- })
376
- ```
377
- or if you like lodash, you can simply `HFS._.remove(menu, { id: 'show' })`
353
+ - add or manipulate entries of the menu. If you return something, that will be added to the menu.
354
+ You can also delete or replace the content of the `menu` array.
355
+ - parameter `{ entry: DirEntry, menu: FileMenuEntry[], props: FileMenuProp[] }`
356
+ - output `undefined | FileMenuEntry | FileMenuEntry[]`
357
+ ```typescript
358
+ interface FileMenuEntry {
359
+ id?: string,
360
+ label: ReactNode,
361
+ subLabel: ReactNode,
362
+ href?: string, // use this if you want your entry to be a link
363
+ icon?: string, // supports: emoji, name from a limited set
364
+ onClick?: () => (Promisable<boolean>) // return false to not close menu dialog
365
+ //...rest is transfered to <a> element, for example 'target', or 'title'
366
+ }
367
+ type FileMenuProp = { id?: string, label: ReactNode, value: ReactNode } | ReactElement
368
+ ```
369
+ Example, if you want to remove the 'show' item of the menu:
370
+ ```typescript
371
+ HFS.onEvent('fileMenu', ({ entry, menu }) => {
372
+ const index = menu.findIndex(x => x.id === 'show')
373
+ if (index >= 0)
374
+ menu.splice(index, 1)
375
+ })
376
+ ```
377
+ or if you like lodash, you can simply `HFS._.remove(menu, { id: 'show' })`
378
- `fileShow`
379
- - you receive an entry of the list, and optionally produce React Component for visualization.
380
- - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
381
- - output `ReactComponent`
379
+ - you receive an entry of the list, and optionally produce React Component for visualization.
380
+ - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
381
+ - output `ReactComponent`
382
- `showPlay`
383
- - emitted on each file played inside file-show. Use setCover if you want to customize the background picture.
384
- - parameter `{ entry: DirEntry, setCover(uri: string), meta: { title, album, artist, year } }`
383
+ - emitted on each file played inside file-show. Use setCover if you want to customize the background picture.
384
+ - parameter `{ entry: DirEntry, setCover(uri: string), meta: { title, album, artist, year } }`
385
- `menuZip`
386
- - parameter `{ def: ReactNode }`
387
- - output `Html`
386
+ - parameter `{ def: ReactNode }`
387
+ - output `Html`
388
- `userPanelAfterInfo`
389
- - no parameter
390
- - output `Html`
389
+ - no parameter
390
+ - output `Html`
391
- `uriChanged`
392
- - DEPRECATED: use `watchState('uri', callback)` instead.
393
- - parameter `{ uri: string, previous: string }`
392
+ - DEPRECATED: use `watchState('uri', callback)` instead.
393
+ - parameter `{ uri: string, previous: string }`
394
- `sortCompare`
395
- - you can decide the order of entries by comparing two entries.
396
- Return a negative value if entry `a` must appear before `b`, or positive if you want the opposite.
397
- Return zero or any falsy value if you want to leave the order to what the user decided in his options.
398
- - parameter `{ a: DirEntry, b: DirEntry }`
399
- - output `number | undefined`
395
+ - you can decide the order of entries by comparing two entries.
396
+ Return a negative value if entry `a` must appear before `b`, or positive if you want the opposite.
397
+ Return zero or any falsy value if you want to leave the order to what the user decided in his options.
398
+ - parameter `{ a: DirEntry, b: DirEntry }`
399
+ - output `number | undefined`
400
+- `enableEntrySelection`
401
+ - selection of multiple entries is used for some standard actions like deletion or zip.
402
+ When none of such standard actions is permitted on an entry, its selection control (checkbox) is disabled.
403
+ If you want to override this behavior, because you have a custom action that makes use of the selection, return `true`.
404
+ - parameter `{ entry: DirEntry }`
405
+ - output `boolean`
406
+- `entryToggleSelection`
407
+ - an entry is being un/selected
408
+ - parameter `{ entry: DirEntry }`
409
+ - can be prevented
410
- All of the following have no parameters and you are supposed to output `Html` that will be displayed in the described place:
411
- `appendMenuBar` inside menu-bar, at the end
412
- `afterMenuBar` between menu-bar and breadcrumbs
frontend/src/BrowseFiles.ts
+3
-2
@@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom'
4
import { createElement as h, Fragment, memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState,
5
useId} from 'react'
6
import { useEventListener, useMediaQuery, useWindowSize } from 'usehooks-ts'
7
-import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix, isMac, isCtrlKey } from './misc'
7
+import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix, isMac, isCtrlKey, hfsEvent } from './misc'
8
import { Checkbox, CustomCode, iconBtn, Spinner } from './components'
9
import { Head } from './Head'
10
import { DirEntry, state, useSnapState } from './state'
@@ -257,10 +257,11 @@ const Entry = ({ entry, midnight, separator }: EntryProps) => {
257
entry,
258
render: x => x ? h('li', { className, label: separator }, x) : _.remove(state.list, { n }) && null
259
}, showFilter && h(Checkbox, {
260
- disabled: isLink,
260
+ disabled: !entry.canSelect(),
261
'aria-labelledby': ariaId,
262
value: selected[uri] || false,
263
onChange(v) {
264
+ if (hfsEvent('entryToggleSelection', { entry }).isDefaultPrevent()) return
265
if (v)
266
return state.selected[uri] = true
267
delete state.selected[uri]
frontend/src/FilterBar.ts
+8
-12
@@ -4,7 +4,7 @@ import { useDebounce } from 'usehooks-ts'
4
import { Checkbox } from './components'
5
import { useI18N } from './i18n'
6
import { usePath } from './useFetchList'
7
-import { with_ } from './misc'
7
+import { getHFS, with_ } from './misc'
8
9
export function FilterBar() {
10
const { list, filteredList, selected, patternFilter, showFilter } = useSnapState()
@@ -14,13 +14,8 @@ export function FilterBar() {
14
const {t} = useI18N()
15
16
state.patternFilter = useDebounce(showFilter ? filter : '', 300)
17
+ useEffect(() => getHFS().onEvent('entryToggleSelection', () => setAll(false)), [])
18
18
- const sel = Object.keys(selected).length
19
- const fil = filteredList?.length
20
- useEffect(() => {
21
- if (all && sel < (fil || list.length))
22
- setAll(false)
23
- }, [sel])
19
const tabIndex = showFilter ? undefined : -1
20
return h('div', { id: 'filter-bar', style: { display: showFilter ? undefined : 'none' } },
21
h(Checkbox, {
@@ -49,19 +44,20 @@ export function FilterBar() {
44
}
45
}),
46
h('span', {}, [
52
- sel && t('select_count', { n:sel }, "{n} selected"),
53
- fil !== undefined && fil < list.length && t('filter_count', {n:fil}, "{n} filtered"),
47
+ with_(Object.keys(selected).length, n => n && t('select_count', { n }, "{n} selected")),
48
+ with_(filteredList?.length, n => n !== undefined && n < list.length && t('filter_count', {n}, "{n} filtered")),
49
].filter(Boolean).join(', ') ),
50
)
51
57
- function select(will: boolean | undefined) {
52
+ function select(will: boolean | undefined) { // undefined will cause toggle of each element
53
const sel = state.selected
59
- for (const { uri } of state.filteredList || state.list) {
54
+ for (const e of state.filteredList || state.list) {
55
+ const { uri } = e
56
const was = sel[uri] || false
57
if (was === will) continue
58
if (was)
59
delete sel[uri]
64
- else
60
+ else if (e.canSelect())
61
sel[uri] = true
62
}
63
if (will !== undefined)
frontend/src/fileMenu.ts
+2
-3
@@ -29,10 +29,9 @@ interface FileMenuEntry {
29
export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy | FileMenuEntry | 'open' | 'delete' | 'show')[]) {
30
const { uri, isFolder, s } = entry
31
const canRead = !entry.p?.includes('r')
32
- const canArchive = entry.p?.includes('A') || state.props?.can_archive && !entry.p?.includes('a')
32
const canList = !entry.p?.match(/L/i)
33
const forbidden = entry.cantOpen === DirEntry.FORBIDDEN
35
- const cantDownload = forbidden || isFolder && !(canRead && canArchive && canList) // folders needs list+read+archive
34
+ const cantDownload = forbidden || isFolder && !(canRead && entry.canArchive() && canList) // folders needs list+read+archive
35
const menu = [
36
!cantDownload && { id: 'download', label: t`Download`, href: uri + (isFolder ? '?get=zip' : '?dl'), icon: 'download' },
37
state.props?.can_comment && { id: 'comment', label: t`Comment`, icon: 'comment', onClick: () => editComment(entry) },
@@ -50,7 +49,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy
49
return !isFolder || open.onClick ? open : h(LinkClosingDialog, { to: uri, reloadDocument: entry.web }, hIcon(open.icon), open.label)
50
}
51
if (x === 'delete')
53
- return (state.props?.can_delete || entry.p?.includes('d')) && {
52
+ return entry.canDelete() && {
53
id: 'delete',
54
label: t`Delete`,
55
icon: 'delete',
frontend/src/state.ts
+13
-1
@@ -3,7 +3,7 @@
3
import _ from 'lodash'
4
import { proxy, useSnapshot } from 'valtio'
5
import { subscribeKey } from 'valtio/utils'
6
-import { FRONTEND_OPTIONS, getHFS, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc'
6
+import { FRONTEND_OPTIONS, getHFS, hfsEvent, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc'
7
import { DirEntry as ServerDirEntry } from '../../src/api.get_file_list'
8
9
export const state = proxy<typeof FRONTEND_OPTIONS & {
@@ -151,6 +151,18 @@ export class DirEntry implements StringifyProps<ServerDirEntry> {
151
getDefaultIcon() {
152
return hIcon(this.icon === true ? `${this.n}?get=icon` : (this.icon ?? (this.isFolder || this.web ? 'folder' : this.url ? 'link' : ext2type(this.ext) || 'file')))
153
}
154
+
155
+ canArchive() {
156
+ return this.p?.includes('A') || state.props?.can_archive && !this.p?.includes('a')
157
+ }
158
+ canDelete() {
159
+ return state.props?.can_delete || this.p?.includes('d')
160
+ }
161
+ canSelect() {
162
+ if (this.url) return false
163
+ return this.canArchive() || this.canDelete() // selection is used only by zip and delete, but consider custom logic from plugins
164
+ || hfsEvent('enableEntrySelection', { entry: this }).some(Boolean)
165
+ }
166
}
167
export type DirList = DirEntry[]
168
src/api.get_file_list.ts
+2
-2
@@ -40,7 +40,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
40
const walker = walkNode(node, { ctx: admin ? undefined : ctx, onlyFolders, depth: search ? Infinity : 0 })
41
const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
42
const can_upload = admin || hasPermission(node, 'can_upload', ctx)
43
- const fakeChild = await applyParentToChild({ source: 'x' }, node) // can we delete children
43
+ const fakeChild = await applyParentToChild({ source: 'dummy-file' }, node) // used to check permission; simple but but can produce false results
44
const can_delete = admin || hasPermission(fakeChild, 'can_delete', ctx)
45
const can_archive = admin || hasPermission(fakeChild, 'can_archive', ctx)
46
const can_comment = can_upload && areCommentsEnabled()
@@ -123,7 +123,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
123
: !hasPermission(node, 'can_read', ctx) ? 'R'
124
: ''
125
const pd = !can_delete && hasPermission(node, 'can_delete', ctx) ? 'd' : ''
126
- const pa = isFolder && Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A'
126
+ const pa = Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A'
127
return {
128
n: name + (isFolder ? '/' : ''),
129
c: st?.ctime,
src/vfs.ts
+1
-1
@@ -4,7 +4,7 @@ import fs from 'fs/promises'
4
import { basename, dirname, join, resolve } from 'path'
5
import {
6
dirStream, getOrSet, isDirectory, makeMatcher, setHidden, onlyTruthy, isValidFileName, throw_, VfsPerms, Who,
7
- isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting, HTTP_SERVER_ERROR, try_, _log
7
+ isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting, HTTP_SERVER_ERROR, try_
8
} from './misc'
9
import Koa from 'koa'
10
import _ from 'lodash'