main
ts 246 lines 11.7 KB
Raw
1 import {
2 dontBotherWithKeys, formatBytes, getHFS, hfsEvent, hIcon, newDialog, prefix, with_, working,
3 pathEncode, closeDialog, anyDialogOpen, Falsy, operationSuccessful, randomId, err2msg, HIDE_IN_TESTS
4 } from './misc'
5 import { createElement as h, Fragment, isValidElement, MouseEvent, ReactNode, useState } from 'react'
6 import { Btn, Bytes, Spinner } from './components'
7 import _ from 'lodash'
8 import { getEntryIcon, MISSING_PERM } from './BrowseFiles'
9 import { DirEntry, state } from './state'
10 import { deleteFiles } from './menu'
11 import { Link, LinkProps } from 'wouter'
12 import { fileShow, getShowComponent } from './show'
13 import { alertDialog, promptDialog, toast } from './dialog'
14 import { apiCall, useApi } from '@hfs/shared/api'
15 import { inputComment } from './upload'
16 import { cut } from './clip'
17 import { loginDialog } from './login'
18 import { useInterval } from 'usehooks-ts'
19 import i18n from './i18n'
20 import { frontEndApis } from '../../src/frontEndApis'
21 const { t, useI18N } = i18n
22
23 interface FileMenuEntry {
24 id?: string
25 label: ReactNode
26 subLabel?: ReactNode
27 href?: string
28 icon?: string
29 toggled?: boolean
30 onClick?: (ev:MouseEvent) => any
31 }
32
33 export async function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy | FileMenuEntry | 'open' | 'delete' | 'show')[]) {
34 const { uri, isFolder, s } = entry
35 const canRead = !entry.p?.includes('r')
36 const canList = !entry.p?.match(/L/i)
37 const forbidden = entry.cantOpen === DirEntry.FORBIDDEN
38 const cantDownload = forbidden || isFolder && !(canRead && entry.canArchive() && canList) // folders needs list+read+archive
39 const menu = [
40 !cantDownload && { id: 'download', label: t`Download`, href: uri + (isFolder ? '?get=zip' : '?dl'), icon: 'download', target: '_blank' },
41 state.props?.can_comment && { id: 'comment', label: t`Comment`, icon: 'comment', onClick: () => editComment(entry) },
42 ...addToMenu.map(x => {
43 if (x === 'open') {
44 if (forbidden) return
45 const open = {
46 id: 'open',
47 icon: 'play',
48 label: t('file_open', "Open"),
49 href: uri,
50 target: isFolder || entry.web ? undefined : '_blank',
51 rel: isFolder || entry.web ? undefined : 'noopener noreferrer',
52 onClick: makeOnClickOpen(entry)
53 }
54 return !isFolder || open.onClick ? open : h(LinkClosingDialog, { to: uri, reloadDocument: entry.web }, hIcon(open.icon), open.label)
55 }
56 if (x === 'delete')
57 return entry.canDelete() && {
58 id: 'delete',
59 label: t`Delete`,
60 icon: 'delete',
61 onClick: () => deleteFiles([entry.uri])
62 }
63 if (x === 'show')
64 return !entry.cantOpen && getShowComponent(entry) && {
65 id: 'show',
66 label: t`Show`,
67 icon: 'image',
68 onClick: () => fileShow(entry)
69 }
70 return x
71 }),
72 entry.canDelete() && { id: 'rename', label: t`Rename`, icon: 'edit', onClick: () => rename(entry) },
73 entry.canDelete() && { id: 'cut', label: t`Cut`, icon: 'cut', onClick: () => close(cut([entry])) },
74 isFolder && !entry.web && !entry.cantOpen && { id: 'list', label: t`Get list`, href: uri + '?get=list&folders=*', icon: 'list', target: '_blank' },
75 ].filter(Boolean)
76 const folder = entry.n.slice(0, -1 - entry.name.length)
77 const props = [
78 { id: 'name', label: t`Name`, value: entry.name },
79 typeof s === 'number' && { id: 'size', label: t`Size`,
80 value: h(Fragment, {}, formatBytes(s), h('small', { className: HIDE_IN_TESTS }, prefix(' (', s > getHFS().kb && s.toLocaleString(), ')')) ) },
81 entry.m && { id: 'timestamp', label: t`Timestamp`, value: entry.m.toLocaleString() },
82 entry.c && { id: 'creation', label: t`Creation`, value: entry.c.toLocaleString() },
83 folder && {
84 id: 'folder',
85 label: t`Folder`,
86 value: h(Link, {
87 href: (folder.startsWith('/') ? '' : location.pathname) + pathEncode(folder) + '/',
88 onClick: () => closeDialog(null, true)
89 }, folder.replaceAll('/', ' / '))
90 },
91 isFolder && !entry.cantOpen && { id: 'folderSize', label: t`Size`, value: h(FolderSize) },
92 ].filter(Boolean)
93 const res = await Promise.all(hfsEvent('fileMenu', { entry, menu, props }))
94 menu.push(...res.flat().filter(Boolean)) // flat because each plugin may return an array of entries
95 _.remove(menu, (x, i) => _.find(menu, y => x.id ? x.id === y.id : (!y.id && x.label === y.label), i + 1)) // avoid duplicates, keeping later ones
96 const ico = getEntryIcon(entry)
97 const { close } = newDialog({
98 title: isFolder ? t`Folder menu` : t`File menu`,
99 className: 'file-dialog',
100 icon: () => ico,
101 position: Math.min(innerWidth, innerHeight) < 800 ? undefined
102 : [ev.pageX, ev.pageY - scrollY] as [number, number],
103 restoreFocus: ev.screenY || ev.screenX ? false : undefined,
104 Content() {
105 const {t} = useI18N()
106 const { data } = useApi<typeof frontEndApis.get_file_details>('get_file_details', { uris: [entry.uri] })
107 const details = data?.details?.[0]
108 const showProps = [ ...props,
109 with_(renderUploaderFromDetails(details), value =>
110 value && { id: 'uploader', label: t`Uploader`, value })
111 ]
112 return h(Fragment, {},
113 h('dl', { className: 'file-dialog-properties' },
114 dontBotherWithKeys(showProps.map(prop => isValidElement(prop) ? prop
115 : _.isPlainObject(prop) ? h('div', { id: `menu-prop-${prop.id}` }, h('dt', {}, prop.label), h('dd', {}, prop.value))
116 : null
117 )),
118 entry.cantOpen && h('div', {}, hIcon('password', { style: { marginRight: '.5em', marginTop: '.5em' } }), t(MISSING_PERM)),
119 ),
120 h('div', { className: 'file-menu' },
121 dontBotherWithKeys(menu.map((entry: FileMenuEntry, i) => // render menu entries
122 isValidElement(entry) ? entry
123 : entry?.label && h('a', {
124 key: i,
125 href: '#',
126 ..._.omit(entry, ['label', 'icon', 'toggled']),
127 id: entry.id && `menu-entry-${entry.id}`,
128 className: entry.toggled ? 'toggled' : undefined,
129 async onClick(ev: MouseEvent) {
130 if (!entry.href)
131 ev.preventDefault()
132 try {
133 if (false !== await entry.onClick?.(ev))
134 close()
135 }
136 catch(e: any) {
137 alertDialog(e)
138 }
139 }
140 },
141 hIcon(entry.icon || 'file'),
142 h('label', { style: { display: 'flex', flexDirection: 'column' } },
143 h('div', {}, entry.label),
144 h('small', {}, entry.subLabel) )
145 )
146 ))
147 )
148 )
149 }
150 })
151
152 function FolderSize() {
153 const [go, setGo] = useState(false)
154 const [id] = useState(() => randomId())
155 const { data, error, loading } = useApi(go && 'get_folder_size', { uri: entry.uri, id }, { timeout: false })
156 const partial = useApi(loading && 'get_folder_size_partial', { id })
157 useInterval(partial.reload, 1000)
158 return showRes(data || error)
159 || (!loading ? h(Btn, { asText: true, label: t`Calculate`, onClick() { setGo(true) } })
160 : h('span', {}, showRes(partial.data), ' ',
161 h(Btn, { asText: true, label: t`Cancel`, icon: h(Spinner), onClick() { setGo(false) } }) )
162 )
163
164 function showRes(data: any) {
165 return data && (
166 data.code ? err2msg(data)
167 : h('span', {}, h(Bytes, _.pick(data,'bytes')),
168 ' / ', t('n_files', { n: data.files.toLocaleString() }, '{n,plural,one{# file} other{# files}}'),
169 ' / ', t('n_folders', { n: data.folders.toLocaleString() }, '{n,plural,one{# folder} other{# folders}}'),
170 )
171 )
172 }
173 }
174 }
175
176 async function rename(entry: DirEntry) {
177 const dest = await promptDialog(t`Name`, {
178 value: entry.name,
179 title: t`Rename`,
180 onField: el => el.setSelectionRange(0, entry.name.lastIndexOf('.'))
181 })
182 if (!dest) return
183 const { n, uri } = entry
184 await apiCall('rename', { uri, dest }, { modal: working })
185 const MSG = t`Operation successful`
186 if (uri === location.pathname) //current folder
187 return alertDialog(MSG)?.then(() =>
188 getHFS().navigate(uri + '../' + pathEncode(dest) + '/') )
189 // update state instead of re-getting the list
190 const newN = n.replace(/(.*?)[^/]+$/, (_,before) => before + dest)
191 const newEntry = new DirEntry(newN, { key: n, ...entry }) // by keeping the old key, we avoid unmounting the element, that's causing focus lost
192 const i = _.findIndex(state.list, { n })
193 state.list[i] = newEntry
194 // update filteredList too
195 const j = _.findIndex(state.filteredList, { n })
196 if (j >= 0)
197 state.filteredList![j] = newEntry
198 toast(MSG, 'success')
199 }
200
201 async function editComment(entry: DirEntry) {
202 const res = await inputComment(entry.name, entry.comment)
203 if (res === undefined) return
204 await apiCall('comment', { uri: entry.uri, comment: res }, { modal: working })
205 // location is fully encoded, while entry.uri uses pathEncode
206 if (decodeURI(entry.uri) === decodeURI(location.pathname)) // is current folder = is from breadcrumb
207 _.set(state, 'props.comment', res)
208 else
209 updateEntry(entry, e => e.comment = res)
210 operationSuccessful()
211 }
212
213 function updateEntry(entry: DirEntry, cb: (e: DirEntry) => unknown) {
214 cb(_.find(state.list, { n: entry.n })!)
215 }
216
217 export function LinkClosingDialog({ to, reloadDocument, ...props }: LinkProps & { reloadDocument?: boolean }) {
218 return reloadDocument ? h('a', { ...props, href: to }) : h(Link, {
219 ...props,
220 href: '', // workaround to get dialogs and browser-history work correctly
221 async onClick(ev: MouseEvent) {
222 ev.preventDefault()
223 while (anyDialogOpen())
224 await closeDialog()?.closed
225 getHFS().navigate(to)
226 }
227 })
228 }
229
230 export function makeOnClickOpen(entry: DirEntry) {
231 return !entry.cantOpen ? undefined : async (ev: any) => {
232 ev.preventDefault()
233 if (entry.cantOpen === DirEntry.FORBIDDEN)
234 return alertDialog(t`Forbidden`, 'warning')
235 if (!await loginDialog(true, false)) return // if we get here, login is required, and we can go on only if it succeeds
236 if (entry.isFolder && !entry.web) // internal navigation
237 return setTimeout(() => getHFS().navigate(entry.uri)) // couldn't find the reason why navigating sync is reverted, so setTimeout
238 location.href = entry.uri
239 }
240 }
241
242 function renderUploaderFromDetails(details: any) {
243 if (!details) return
244 const { upload: u } = details
245 return u && `${u.username||''}${prefix('@', u.ip)}`
246 }