file show
Massimo Melina committed
Jun 16, 2023 at 02:10 UTC
e550344fcfe2492c401566e864f90f43afbbf917
13 files changed
+251
-51
admin/src/misc.ts
+1
-4
@@ -17,7 +17,7 @@ import { SxProps } from '@mui/system'
17
import { Refresh, SvgIconComponent } from '@mui/icons-material'
18
import { alertDialog, confirmDialog } from './dialog'
19
import { apiCall } from './api'
20
-import { dontBotherWithKeys, formatPerc, useStateMounted } from '@hfs/shared'
20
+import { dontBotherWithKeys, formatPerc, useStateMounted, WIKI_URL } from '@hfs/shared'
21
import { Promisable } from '@hfs/mui-grid-form'
22
import { LoadingButton, LoadingButtonProps } from '@mui/lab'
23
export * from '@hfs/shared'
@@ -199,9 +199,6 @@ export function Flex({ gap='.8em', vert=false, children=null, props={}, ...rest
199
}
200
201
202
-export const REPO_URL = 'https://github.com/rejetto/hfs/'
203
-export const WIKI_URL = REPO_URL + '/wiki/'
204
-
202
export function wikiLink(uri: string, content: ReactNode) {
203
if (Array.isArray(content))
204
content = dontBotherWithKeys(content)
dev-plugins.md
+9
-4
@@ -258,6 +258,10 @@ This is a list of available frontend-events, with respective object parameter an
258
}
259
type FileMenuProp = [ReactNode,ReactNode] | ReactElement
260
```
261
+- `fileShow`
262
+ - you receive an entry of the list, and optionally produce React Component for visualization.
263
+ - parameter `{ entry: Entry }` (refer above for Entry object)
264
+ - output `ReactComponent`
265
266
## Other files
267
@@ -298,12 +302,13 @@ HFS will scan through them in inverted alphabetical order searching for a compat
302
303
## API version history
304
301
-- 8.21 (v0.46.0)
305
+- 8.22 (v0.46.0)
306
- entry.getNext, getPrevious, getNextFiltered, getPreviousFiltered, getDefaultIcon
307
- platform-dependent distribution
308
- HFS.watchState, emit
309
- api.storageDir, customApiCall
310
- exports.depend
311
+ - new event: fileShow
312
- 8.1 (v0.45.0) should have been 0.44.0 but forgot to update number
313
- full URL support for frontend_js and frontend_css
314
- custom.html
@@ -311,18 +316,18 @@ HFS will scan through them in inverted alphabetical order searching for a compat
316
- HFS.apiCall, reloadList, logout, h, React, state, t, _, dialogLib, Icon, getPluginPublic
317
- second parameter of onEvent is now deprecated
318
- renamed: additionalEntryProps > additionalEntryDetails & entry-props > entry-details
314
- - entryIcon event
319
+ - new event: entryIcon
320
- 8 (v0.43.0)
321
- entry.name & .uri
322
- tools.dialogLib
323
- HFS.getPluginConfig()
324
- 7 (v0.42.0)
320
- - event.fileMenu
325
+ - new event: fileMenu
326
- HFS.SPECIAL_URI, PLUGINS_PUB_URI, FRONTEND_URI,
327
- 6 (v0.38.0)
328
- config.frontend
329
- 5 (v0.33.0)
325
- - event.afterEntryName
330
+ - new event: afterEntryName
331
- 4.1 (v0.23.4)
332
- config.type:array added $width, $column and fixed height
333
- 4 (v0.23.0)
frontend/src/BrowseFiles.ts
+6
-8
@@ -22,7 +22,6 @@ import { useAuthorized } from './login'
22
import { acceptDropFiles, enqueue } from './upload'
23
import _ from 'lodash'
24
import { t, useI18N } from './i18n'
25
-import { deleteFiles } from './menu'
25
import { openFileMenu } from './fileMenu'
26
27
export const MISSING_PERM = "Missing permission"
@@ -158,7 +157,7 @@ const Paging = memo(({ nPages, current, pageSize, pageChange, atBottom }: Paging
157
)
158
})
159
161
-function useMidnight() {
160
+export function useMidnight() {
161
const [midnight, setMidnight] = useState(calcMidnight)
162
useEffect(() => {
163
setTimeout(()=> setMidnight(calcMidnight()), 10 * 60_000) // refresh every 10 minutes
@@ -180,7 +179,7 @@ interface EntryProps { entry: DirEntry, midnight: Date, separator?: string }
179
const Entry = memo(({ entry, midnight, separator }: EntryProps) => {
180
const { uri, isFolder } = entry
181
const base = usePath()
183
- const { showFilter, selected, can_delete } = useSnapState()
182
+ const { showFilter, selected } = useSnapState()
183
const containerDir = isFolder ? '' : uri.substring(0, uri.lastIndexOf('/')+1)
184
const containerName = containerDir && entry.n.slice(0, -entry.name.length)
185
let className = isFolder ? 'folder' : 'file'
@@ -223,11 +222,10 @@ const Entry = memo(({ entry, midnight, separator }: EntryProps) => {
222
function fileMenu(ev: MouseEvent) {
223
if (ev.altKey || ev.ctrlKey || ev.metaKey) return
224
ev.preventDefault()
226
- const open = { icon: 'play', label: t('file_open', "Open"), href: uri, target: isFolder ? undefined : '_blank' }
225
openFileMenu(entry, ev, [
228
- menuOnLink && !entry.cantOpen && (
229
- !isFolder ? open : h(Link, { to: base + uri, onClick: () => close() }, hIcon(open.icon), open.label) ),
230
- can_delete && { label: t`Delete`, icon: 'trash', onClick: () => deleteFiles([uri], base) }
226
+ menuOnLink && 'open',
227
+ 'delete',
228
+ 'show'
229
])
230
}
231
@@ -241,7 +239,7 @@ export function getEntryIcon(entry: DirEntry) {
239
})
240
}
241
244
-const EntryDetails = memo(({ entry, midnight }: { entry: DirEntry, midnight: Date }) => {
242
+export const EntryDetails = memo(({ entry, midnight }: { entry: DirEntry, midnight: Date }) => {
243
const { t: time, s } = entry
244
const today = time && time > midnight
245
const shortTs = useWindowSize().width < 800
frontend/src/components.ts
+17
-2
@@ -2,6 +2,7 @@
2
3
import { getHFS, hfsEvent, hIcon } from './misc'
4
import {
5
+ ButtonHTMLAttributes,
6
ChangeEvent,
7
createElement as h,
8
FC,
@@ -17,12 +18,13 @@ export function Spinner(props: any) {
18
return hIcon('spinner', { className:'spinner', ...props })
19
}
20
20
-export function Flex({ gap='.8em', vert=false, children=null, props={}, ...rest }) {
21
+export function Flex({ gap='.8em', center=false, vert=false, children=null, props={}, ...rest }) {
22
return h('div', {
23
style: {
24
display: 'flex',
25
gap,
26
flexDirection: vert ? 'column' : undefined,
27
+ ...center && { alignItems: 'center', justifyContent: 'center' },
28
...rest,
29
},
30
...props
@@ -80,4 +82,17 @@ export function CustomCode({ name, props, ifEmpty }: { name: string, props?: any
82
return ret
83
}, props ? Object.values(props) : [])
84
return children.length || !ifEmpty ? h(Fragment, {}, children) : h(ifEmpty)
83
-}
\ No newline at end of file
85
+}
86
+
87
+interface IconBtnOptions extends ButtonHTMLAttributes<any> { small?: boolean, style?: any }
88
+export function iconBtn(icon: string, onClick: () => any, { small=true, style={}, ...props }: IconBtnOptions={}) {
89
+ return h('button', {
90
+ onClick,
91
+ ...props,
92
+ ...small && {
93
+ style: { padding: '.1em', width: 35, height: 30, ...style }
94
+ }
95
+ },
96
+ icon.length > 1 ? hIcon(icon) : icon
97
+ )
98
+}
frontend/src/fileMenu.ts
+25
-4
@@ -3,7 +3,10 @@ import { dontBotherWithKeys, formatBytes, hfsEvent, hIcon, newDialog } from './m
3
import { createElement as h, Fragment, isValidElement, MouseEventHandler, ReactNode } from 'react'
4
import _ from 'lodash'
5
import { getEntryIcon, MISSING_PERM } from './BrowseFiles'
6
-import { DirEntry } from './state'
6
+import { DirEntry, state } from './state'
7
+import { deleteFiles } from './menu'
8
+import { Link } from 'react-router-dom'
9
+import { fileShow, getShowType } from './show'
10
11
interface FileMenuEntry {
12
label: ReactNode
@@ -12,12 +15,31 @@ interface FileMenuEntry {
15
onClick?: MouseEventHandler
16
}
17
15
-export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: FileMenuEntry[]) {
18
+export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMenuEntry | 'open' | 'delete' | 'show')[]) {
19
const { uri, isFolder } = entry
20
const cantDownload = entry.cantOpen || isFolder && entry.p?.includes('r') // folders needs both list and read
21
const menu = [
22
!cantDownload && { label: t`Download`, href: uri + (isFolder ? '?get=zip' : '?dl'), icon: 'download' },
20
- ...addToMenu,
23
+ ...addToMenu.map(x => {
24
+ if (x === 'open') {
25
+ if (entry.cantOpen) return
26
+ const open = { icon: 'play', label: t('file_open', "Open"), href: uri, target: isFolder ? undefined : '_blank' }
27
+ return !isFolder ? open : h(Link, { to: location.pathname + uri, onClick: () => close() }, hIcon(open.icon), open.label)
28
+ }
29
+ if (x === 'delete')
30
+ return state.can_delete && {
31
+ label: t`Delete`,
32
+ icon: 'trash',
33
+ onClick: () => deleteFiles([entry.uri], entry.uri[0] === '/' ? '' : location.pathname)
34
+ }
35
+ if (x === 'show')
36
+ return !entry.cantOpen && getShowType(entry) && {
37
+ label: t`Show`,
38
+ icon: 'image',
39
+ onClick: () => fileShow(entry)
40
+ }
41
+ return x
42
+ }),
43
isFolder && { label: t`Get list`, href: uri + '?get=list&folders=*', icon: 'list' }
44
]
45
const props = [
@@ -64,4 +86,3 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: FileMen
86
}
87
})
88
}
67
-
frontend/src/icons.ts
+2
@@ -9,6 +9,8 @@ const SYS_ICONS: Record<string, string[]> = {
9
filter: ['✂'],
10
search: ['🔍'],
11
search_off: ['❌','cancel'],
12
+ close: ['❌','cancel'],
13
+ error: ['❌','cancel'],
14
stop: ['⏹️'],
15
settings: ['⚙','cog'],
16
archive: ['📦'],
frontend/src/index.scss
+66
-10
@@ -75,6 +75,9 @@ select { text-align: center; } // it is surely cooler on the options dialog
75
76
.hidden { display: none !important }
77
78
+[class^="fa-"]:before, [class*=" fa-"]:before { /* don't need extra margin on fontello icons */
79
+ margin: 0;
80
+}
81
.icon {
82
font-size: 1.2em;
83
height: 1.2em; width: 1.4em; // give similar size to <img> icons
@@ -321,7 +324,7 @@ ul.dir {
324
325
button label {
326
cursor: inherit;
324
- margin-left: 0.5em;
327
+ margin-left: .7em;
328
}
329
330
.dialog-backdrop.working {
@@ -544,6 +547,44 @@ button label {
547
548
}
549
550
+#root // heavier selector
551
+.file-show {
552
+ &>div {
553
+ height: 100%;
554
+ width: 100%;
555
+ }
556
+ .showing {
557
+ max-width: calc(100% - var(--nav-size)*2); // avoid overlapping of controls and nav buttons
558
+ max-height: 100%;
559
+ }
560
+ img.showing { max-width: 100% } // we are ok with overlapping for simple images
561
+ --nav-size: min(25vh, 15vw);
562
+ .nav {
563
+ position: absolute;
564
+ margin: -.4em; // closer to the edge
565
+ font-size: var(--nav-size); // big
566
+ cursor: pointer;
567
+ opacity: .3;
568
+ -webkit-text-stroke: 2px black;
569
+ user-select: none;
570
+ transition: opacity .3s;
571
+ &:hover { opacity: .7; }
572
+ }
573
+ .bar {
574
+ padding: .5em 1em;
575
+ background: var(--bg);
576
+ opacity: .8;
577
+ display: flex;
578
+ flex-wrap: wrap;
579
+ align-items: center;
580
+ justify-content: flex-end;
581
+ gap: .5em 1.5em;
582
+
583
+ .entry-details { font-size: smaller; }
584
+ .entry-ts { display: inherit; }
585
+ .entry-size::after { display: none }
586
+ }
587
+}
588
589
@media (min-width: 42em) {
590
body {
@@ -575,17 +616,32 @@ button label {
616
}
617
#filter-bar {
618
margin-top: 0.4em;
578
- }
579
- #filter-bar button { /* make it same size of top bar */
580
- width: 17.6vw;
581
- height: 2.3em;
619
+ button { // make it same size of top bar
620
+ width: 17.6vw;
621
+ height: 2.3em;
622
+ }
623
}
624
.breadcrumb {
584
- word-break: break-all; /* solves with very long names without spaces. 'break-word' is nicer but doesn't handle worst
585
- cases like /gear/mininova/x/LOOPMASTERS%204Gig%20Pack/LOOPMASTERS_2015/BASS_HOUSE_AND_GARAGE_2_DEMOS/SOUNDS_AND_FX/
586
- */
625
+ word-break: break-all; // solves with very long names without spaces. 'break-word' is nicer but doesn't handle worst cases like /gear/mininova/x/LOOPMASTERS%204Gig%20Pack/LOOPMASTERS_2015/BASS_HOUSE_AND_GARAGE_2_DEMOS/SOUNDS_AND_FX/
626
+ .icon {
627
+ font-size:24px;
628
+ }
629
}
588
- .breadcrumb .icon {
589
- font-size:24px;
630
+ #root>.tiles-mode {
631
+ margin: 0;
632
}
633
}
634
+
635
+@media (max-height: 600px) {
636
+ .file-dialog {
637
+ .dialog-content {
638
+ display: flex;
639
+ gap: 3em;
640
+ margin: 1em;
641
+ .file-menu {
642
+ margin-top: 0;
643
+ a:first-child { border-top: none; }
644
+ }
645
+ }
646
+ }
647
+}
\ No newline at end of file
frontend/src/show.ts
new
+106
@@ -0,0 +1,106 @@
1
+import { DirEntry, state } from './state'
2
+import { createElement as h, Fragment, useRef, useState } from 'react'
3
+import { hfsEvent, hIcon, newDialog, restartAnimation, WIKI_URL } from './misc'
4
+import { useEventListener, useWindowSize } from 'usehooks-ts'
5
+import { EntryDetails, useMidnight } from './BrowseFiles'
6
+import { Flex, FlexV, iconBtn } from './components'
7
+import { openFileMenu } from './fileMenu'
8
+import { useI18N } from './i18n'
9
+
10
+export function fileShow(entry: DirEntry) {
11
+ const close = newDialog({
12
+ noFrame: true,
13
+ className: 'file-show',
14
+ Content() {
15
+ const [cur, setCur] = useState(entry)
16
+ const moving = useRef(0)
17
+ const lastGood = useRef(entry)
18
+ useEventListener('keydown', ({ key }) => {
19
+ if (key === 'ArrowLeft')
20
+ return go(-1)
21
+ if (key === 'ArrowRight')
22
+ return go(+1)
23
+ if (key === 'ArrowDown')
24
+ return location.href = cur.uri + '?dl'
25
+ if (key === ' ') {
26
+ const sel = state.selected
27
+ if (sel[cur.uri])
28
+ delete sel[cur.uri]
29
+ else
30
+ sel[cur.uri] = true
31
+ state.showFilter = true
32
+ return
33
+ }
34
+ })
35
+ const [failed, setFailed] = useState<false | string>(false)
36
+ const {t} = useI18N()
37
+ return h(Fragment, {},
38
+ h(FlexV, { gap: 0, alignItems: 'stretch' },
39
+ h('div', { className: 'bar' },
40
+ h('div', { className: 'filename' }, cur.n),
41
+ h(EntryDetails, { entry, midnight: useMidnight() }),
42
+ h(Flex, {},
43
+ useWindowSize().width > 1280 && iconBtn('?', () => window.open(WIKI_URL + 'File-show')),
44
+ iconBtn('menu', ev => openFileMenu(cur, ev, ['open','delete'])),
45
+ iconBtn('close', close),
46
+ )
47
+ ),
48
+ h(FlexV, { flex: 1, center: true, position: 'relative', maxHeight: '100%',
49
+ overflow: 'hidden' // without this <video> can make me go beyond the screen limit
50
+ },
51
+ failed === cur.n ? h(FlexV, { alignItems: 'center', textAlign: 'center' },
52
+ hIcon('error', { style: { fontSize: '20vh' } }),
53
+ h('div', {}, cur.name),
54
+ t`Loading failed`
55
+ ) : h(getShowType(cur) || Fragment, {
56
+ src: cur.uri,
57
+ className: 'showing',
58
+ onLoad: () => lastGood.current = cur,
59
+ onError: () => {
60
+ go()
61
+ setFailed(cur.n)
62
+ }
63
+ }),
64
+ hIcon('❮', { className: 'nav', style: { left: 0 }, onClick: () => go(-1) }),
65
+ hIcon('❯', { className: 'nav', style: { right: 0 }, onClick: () => go(+1) }),
66
+ ),
67
+ )
68
+ )
69
+
70
+ function go(dir?: number) {
71
+ if (dir)
72
+ moving.current = dir
73
+ let e = cur
74
+ setFailed(false)
75
+ while (1) {
76
+ e = e.getSibling(moving.current)
77
+ if (!e) { // reached last
78
+ setCur(lastGood.current) // revert to last known supported file
79
+ return restartAnimation(document.body, '.2s blink')
80
+ }
81
+ if (!e.isFolder && getShowType(e)) break // give it a chance
82
+ }
83
+ setCur(e)
84
+ }
85
+ }
86
+ })
87
+}
88
+
89
+export function getShowType(entry: DirEntry) {
90
+ const res = hfsEvent('fileShow', { entry }).find(Boolean)
91
+ if (res)
92
+ return res
93
+ const { n } = entry
94
+ return /\.(jpe?g|gif|png|webp|svg)$/i.test(n) ? 'img'
95
+ : /\.(mp3|wav|m4a)$/i.test(n) ? Audio
96
+ : /\.(mp4|mpe?g|webm|mov|m4v)$/i.test(n) ? Video
97
+ : ''
98
+}
99
+
100
+function Audio({ onLoad, ...rest }: any) {
101
+ return h('audio', { onLoadedData: onLoad, controls: true, ...rest })
102
+}
103
+
104
+function Video({ onLoad, ...rest }: any) {
105
+ return h('video', { onLoadedData: onLoad, controls: true, ...rest })
106
+}
\ No newline at end of file
frontend/src/upload.ts
+1
-13
@@ -1,7 +1,7 @@
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, Fragment, useMemo, useState } from 'react'
4
-import { Checkbox, Flex, FlexV } from './components'
4
+import { Checkbox, Flex, FlexV, iconBtn } from './components'
5
import {
6
closeDialog,
7
DialogCloser,
@@ -198,18 +198,6 @@ function FilesList({ files, remove }: { files: File[], remove: (f:File) => any }
198
)
199
}
200
201
-function iconBtn(icon: string, onClick: () => any, { small=true, style={}, ...props }={}) {
202
- return h('button', {
203
- onClick,
204
- ...props,
205
- ...small && {
206
- style: { padding: '.1em', width: 35, height: 30, ...style }
207
- }
208
- },
209
- icon.length > 1 ? hIcon(icon) : icon
210
- )
211
-}
212
-
201
function formatTime(time: number, decimals=0, length=Infinity) {
202
time /= 1000
203
const ret = [(time % 1).toFixed(decimals).slice(1)]
shared/index.ts
+8
@@ -5,6 +5,9 @@ export * from './react'
5
export * from './dialogs'
6
export * from './srp'
7
8
+export const REPO_URL = 'https://github.com/rejetto/hfs/'
9
+export const WIKI_URL = REPO_URL + 'wiki/'
10
+
11
export type Dict<T=any> = Record<string, T>
12
export type Falsy = false | null | undefined | '' | 0
13
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T
@@ -106,6 +109,11 @@ export function domOn<K extends keyof WindowEventMap>(eventName: K, cb: (ev: Win
109
return () => target.removeEventListener(eventName, cb)
110
}
111
112
+export function restartAnimation(e: HTMLElement, animation: string) {
113
+ e.style.animation = ''
114
+ void e.offsetWidth
115
+ e.style.animation = animation
116
+}
117
118
export function findFirst<I, O>(a: I[] | Record<string, I>, cb:(v:I, k: string | number)=>O): any {
119
if (a) for (const k in a) {
src/const.ts
+1
-1
@@ -16,7 +16,7 @@ const pkg = JSON.parse(fs.readFileSync(PKG_PATH,'utf8'))
16
export const VERSION = pkg.version
17
export const DAY = 86_400_000
18
19
-export const API_VERSION = 8.21
19
+export const API_VERSION = 8.22
20
export const COMPATIBLE_API_VERSION = 1 // while changes in the api are not breaking, this number stays the same, otherwise it is made equal to API_VERSION
21
22
export const HFS_REPO = 'rejetto/hfs'
src/langs/hfs-lang-en.json
+4
-2
@@ -1,7 +1,7 @@
1
{
2
"author": "YOUR NAME HERE",
3
"version": 1.0,
4
- "hfs_version": "0.43.0",
4
+ "hfs_version": "0.46.0",
5
"translate": {
6
"Select": "Select",
7
"n_files": "{n,plural,one{# file} other{# files}}",
@@ -114,6 +114,8 @@
114
"Get list": "Get list",
115
"Skip existing files": "Skip existing files",
116
"Size": "Size",
117
- "Timestamp": "Timestamp"
117
+ "Timestamp": "Timestamp",
118
+ "Show": "Show",
119
+ "Loading failed": "Loading failed"
120
}
121
}
src/langs/hfs-lang-it.json
+5
-3
@@ -1,7 +1,7 @@
1
{
2
"author": "Massimo Melina",
3
- "version": 1.5,
4
- "hfs_version": "0.43.0",
3
+ "version": 1.6,
4
+ "hfs_version": "0.46.0",
5
"translate": {
6
"Select": "Seleziona",
7
"n_files": "{n} file",
@@ -108,6 +108,8 @@
108
"Get list": "Lista",
109
"Skip existing files": "Salta file esistenti",
110
"Size": "Dimensione",
111
- "Timestamp": "Data/ora"
111
+ "Timestamp": "Data/ora",
112
+ "Show": "Show",
113
+ "Loading failed": "Caricamento fallito"
114
}
115
}