main
ts 255 lines 10.4 KB
Raw
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 }