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 }