| 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 { state, useSnapState } from './state' |
| 4 | import { createElement as h, Fragment, useEffect, useMemo, useState } from 'react' |
| 5 | import { alertDialog, confirmDialog, ConfirmOptions, formDialog, toast } from './dialog' |
| 6 | import { |
| 7 | err2msg, ErrorMsg, onlyTruthy, prefix, useStateMounted, working, buildUrlQueryString, hIcon, |
| 8 | WIKI_URL, HTTP_NOT_FOUND |
| 9 | } from './misc' |
| 10 | import { loginDialog } from './login' |
| 11 | import { showOptions } from './options' |
| 12 | import showUserPanel from './UserPanel' |
| 13 | import _ from 'lodash' |
| 14 | import { closeDialog } from '@hfs/shared/dialogs' |
| 15 | import { showUpload } from './upload' |
| 16 | import { uploadState } from './uploadQueue' |
| 17 | import { useSnapshot } from 'valtio' |
| 18 | import { apiCall } from '@hfs/shared/api' |
| 19 | import { reloadList } from './useFetchList' |
| 20 | import { cut } from './clip' |
| 21 | import { Btn, BtnProps, Checkbox, CustomCode } from './components' |
| 22 | import i18n from './i18n' |
| 23 | import { encodeUrlList } from '../../src/urlList' |
| 24 | const { t, useI18N } = i18n |
| 25 | |
| 26 | export function MenuPanel() { |
| 27 | const { showFilter, remoteSearch, stopSearch, searchManuallyInterrupted, selected, props } = useSnapState() |
| 28 | const { can_upload, can_delete_children, can_archive } = props || {} |
| 29 | const { uploading, qs, uploadDialogIsOpen } = useSnapshot(uploadState) |
| 30 | useEffect(() => { |
| 31 | if (!showFilter) |
| 32 | state.selected = {} |
| 33 | }, [showFilter]) |
| 34 | |
| 35 | const {t} = useI18N() |
| 36 | |
| 37 | const [justStarted, setJustStarted] = useStateMounted(false) |
| 38 | useEffect(() => { |
| 39 | if (!stopSearch) return |
| 40 | setJustStarted(false) |
| 41 | setTimeout(() => setJustStarted(true), 1000) |
| 42 | }, [stopSearch, setJustStarted]) |
| 43 | |
| 44 | // compact repeated folder names so search selections from the same folders are less likely to hit URL limits (64kb) |
| 45 | const ofs = location.pathname.length |
| 46 | const list = useMemo(() => encodeUrlList( |
| 47 | Object.keys(selected).map(s => s.slice(ofs, s.endsWith('/') ? -1 : Infinity)) |
| 48 | ), [selected]) |
| 49 | |
| 50 | // avoid useless dom changes while we are still waiting for necessary data |
| 51 | const [changingButton, setChangingButton] = useState<'' | 'upload' | 'delete'>('') |
| 52 | useEffect(() => { |
| 53 | if (can_upload !== undefined) |
| 54 | setChangingButton(showFilter && can_delete_children ? 'delete' : (can_upload || qs.length > 0) ? 'upload' : '') |
| 55 | }, [showFilter, can_delete_children, can_upload, qs.length]) |
| 56 | return h('div', { id: 'menu-panel' }, |
| 57 | h('div', { id: 'menu-bar' }, |
| 58 | h(LoginButton), |
| 59 | h(Btn, { |
| 60 | id: 'select-button', |
| 61 | icon: 'check', |
| 62 | label: t`Select`, |
| 63 | tooltip: t('select_tooltip', `Selection applies to "Zip" and "Delete" (when available), but you can also filter the list`), |
| 64 | toggled: showFilter, |
| 65 | onClick() { |
| 66 | state.showFilter = !showFilter |
| 67 | } |
| 68 | }), |
| 69 | h(Btn, changingButton === 'delete' ? { |
| 70 | id: 'delete-button', |
| 71 | icon: 'delete', |
| 72 | label: t`Delete`, |
| 73 | className: 'show-sliding', |
| 74 | disabled: !list, |
| 75 | tooltip: t('delete_select', "Select something to delete"), |
| 76 | onClick: () => deleteFiles(Object.keys(selected)) |
| 77 | } : { |
| 78 | id: 'upload-button', |
| 79 | icon: 'upload', |
| 80 | label: t`Upload`, |
| 81 | disabled: !changingButton, |
| 82 | tabIndex: changingButton ? undefined : -1, |
| 83 | className: 'sliding ' + (changingButton ? '' : 'hide-sliding') + (uploading && !uploadDialogIsOpen ? ' ani-working' : ''), |
| 84 | onClick: showUpload, |
| 85 | }), |
| 86 | h(Btn, showFilter && can_delete_children ? { |
| 87 | id: 'cut-button', |
| 88 | icon: 'cut', |
| 89 | label: t`Cut`, |
| 90 | onClick() { |
| 91 | cut(onlyTruthy(Object.keys(selected).map(uri => _.find(state.list, { uri })))) |
| 92 | } |
| 93 | } : stopSearch && justStarted ? { // don't change the state of the search button immediately to avoid it flicking at every folder change |
| 94 | id: 'search-stop-button', |
| 95 | icon: 'stop', |
| 96 | label: t`Stop list`, |
| 97 | className: 'ani-working', |
| 98 | onClick() { |
| 99 | stopSearch() |
| 100 | state.searchManuallyInterrupted = true |
| 101 | } |
| 102 | } : { |
| 103 | id: 'search-button', |
| 104 | icon: 'search', |
| 105 | label: t`Search`, |
| 106 | onClickAnimation: false, |
| 107 | onClick: searchDialog, |
| 108 | }), |
| 109 | h(Btn, { |
| 110 | id: 'options-button', |
| 111 | icon: 'options', |
| 112 | label: t`Options`, |
| 113 | onClick: showOptions |
| 114 | }), |
| 115 | h(CustomCode, { name: 'menuZip' }, h(MenuLink, { |
| 116 | id: 'zip-button', |
| 117 | icon: 'archive', |
| 118 | label: t`Zip`, |
| 119 | disabled: !can_archive, |
| 120 | tooltip: list ? t('zip_tooltip_selected', "Download selected elements as a single zip file") |
| 121 | : t('zip_tooltip_whole', "Download whole list (unfiltered) as a single zip file. If you select some elements, only those will be downloaded."), |
| 122 | href: buildUrlQueryString(_.pickBy({ |
| 123 | get: 'zip', |
| 124 | ...remoteSearch, |
| 125 | list: isAllSelected() ? undefined : list |
| 126 | })), |
| 127 | ...!list && { |
| 128 | confirm: remoteSearch ? t('zip_confirm_search', "Download ALL results of this search as ZIP archive?") |
| 129 | : t('zip_confirm_folder', "Download WHOLE folder as ZIP archive?"), |
| 130 | confirmOptions: { |
| 131 | afterButtons: h('button', { |
| 132 | onClick() { |
| 133 | state.showFilter = true |
| 134 | closeDialog(false) |
| 135 | return alertDialog(t('zip_checkboxes', "Use checkboxes to select the files, then you can use Zip again")) |
| 136 | }, |
| 137 | }, t`Select some files`), |
| 138 | } |
| 139 | } |
| 140 | })), |
| 141 | h(CustomCode, { name: 'appendMenuBar' }), |
| 142 | ), |
| 143 | remoteSearch && h('div', { id: 'searched' }, |
| 144 | !stopSearch && h(Btn, { |
| 145 | id: 'search-clear-button', |
| 146 | icon: 'search_off', |
| 147 | label: t`Clear search`, |
| 148 | className: 'small', |
| 149 | onClick() { |
| 150 | state.remoteSearch = undefined |
| 151 | } |
| 152 | }), |
| 153 | (stopSearch ? t`Searching` : t`Searched`) + ': ', |
| 154 | _.map({ search: t`Name`, searchComment: t`Comment` } satisfies { [K in RSK]?: string }, |
| 155 | (v,k) => prefix(v + ': ', remoteSearch[k as RSK])).filter(Boolean).join(' and '), |
| 156 | prefix(' (', searchManuallyInterrupted && t`Interrupted`, ')'), |
| 157 | ), |
| 158 | ) |
| 159 | type RSK = keyof typeof remoteSearch |
| 160 | } |
| 161 | |
| 162 | function isAllSelected() { |
| 163 | return state.list.every(x => state.selected[x.uri]) |
| 164 | } |
| 165 | |
| 166 | export function MenuLink({ href, target, confirm, confirmOptions, id, ...rest }: BtnProps & { href: string, target?: string, confirm?: string, confirmOptions?: ConfirmOptions }) { |
| 167 | return h('a', { |
| 168 | tabIndex: -1, |
| 169 | href, |
| 170 | target, |
| 171 | id, |
| 172 | async onClick(ev) { |
| 173 | if (!confirm) return |
| 174 | ev.preventDefault() |
| 175 | await confirmDialog(confirm, { href, title: t`Confirm`, ...confirmOptions }) |
| 176 | } |
| 177 | }, h(Btn, rest)) |
| 178 | } |
| 179 | |
| 180 | function LoginButton() { |
| 181 | const snap = useSnapState() |
| 182 | const {t} = useI18N() |
| 183 | return Btn(snap.username ? { |
| 184 | id: 'user-button', |
| 185 | className: 'toggled', // without aria-pressed |
| 186 | icon: 'user', |
| 187 | label: snap.username, |
| 188 | onClick: showUserPanel |
| 189 | } : { |
| 190 | id: 'login-button', |
| 191 | icon: 'login', |
| 192 | label: t`Login`, |
| 193 | onClickAnimation: false, |
| 194 | onClick: () => loginDialog(), |
| 195 | }) |
| 196 | } |
| 197 | |
| 198 | export async function deleteFiles(uris: string[]) { |
| 199 | const n = uris.length |
| 200 | if (!await confirmDialog(t('delete_confirm', {n}, "Delete {n,plural, one{# item} other{# items}}?"))) |
| 201 | return false |
| 202 | const stop = working() |
| 203 | const errors = onlyTruthy(await Promise.all(uris.map(uri => |
| 204 | apiCall('delete', {}, { restUri: uri }) |
| 205 | .then(() => null, err => err?.code !== HTTP_NOT_FOUND && { uri, err }) |
| 206 | ))) |
| 207 | stop() |
| 208 | reloadList() |
| 209 | const e = errors.length |
| 210 | const msg = t('delete_completed', {n: n-e}, "Deletion: {n} completed") |
| 211 | if (n === 1 && !e) |
| 212 | return toast(msg, 'success') |
| 213 | void alertDialog(h(Fragment, {}, |
| 214 | msg, e > 0 && t('delete_failed', {n:e}, ", {n} failed"), |
| 215 | h('div', { style: { textAlign: 'left', marginTop: '1em', } }, |
| 216 | ...errors.map(e => h(ErrorMsg, { err: t(err2msg(e.err)) + ': ' + e.uri })) |
| 217 | ) |
| 218 | )) |
| 219 | } |
| 220 | |
| 221 | function searchDialog() { |
| 222 | const was = state.remoteSearch |
| 223 | formDialog({ |
| 224 | title: t`Search`, |
| 225 | dialogProps: { id: 'search-dialog' }, |
| 226 | Content() { |
| 227 | const style = { width: 0, minWidth: '100%', maxWidth: '100%', boxSizing: 'border-box' } |
| 228 | return h(Fragment, {}, |
| 229 | t('search_msg', "Search this folder and sub-folders"), |
| 230 | h('div', { className: 'field name' }, |
| 231 | h('label', { htmlFor: 'name' }, t`Name`), |
| 232 | h('input', { name: 'name', style, autoFocus: true, defaultValue: was?.search }), |
| 233 | ), |
| 234 | h('div', { className: 'field comment' }, |
| 235 | h('label', { htmlFor: 'comment' }, t`Comment`), |
| 236 | h('input', { name: 'comment', style, defaultValue: was?.searchComment }), |
| 237 | ), |
| 238 | h('div', { className: 'field wildcards' }, |
| 239 | h(Checkbox, { name: 'wild', defaultChecked: !was?.wild }, t`Wildcards`, |
| 240 | h('a', { href: `${WIKI_URL}Wildcards`, target: 'doc' }, hIcon('info'))), // uncontrolled |
| 241 | ), |
| 242 | h('div', { className: 'submit' }, |
| 243 | h('button', {}, t`Continue`)), |
| 244 | ) |
| 245 | } |
| 246 | }).then(res => { |
| 247 | if (!res) return |
| 248 | state.remoteSearch = !res.name && !res.comment ? undefined : { |
| 249 | search: res.name || undefined, |
| 250 | searchComment: res.comment || undefined, |
| 251 | wild: res.wild ? undefined : 'no' |
| 252 | } |
| 253 | state.stopSearch?.() |
| 254 | }) |
| 255 | } |