dialogs
Massimo Melina committed
Dec 25, 2021 at 23:26 UTC
a04c0f18fbacf8d4f6ad770e79c69963cb83ab33
5 files changed
+189
-6
frontend/src/App.tsx
+9
-5
@@ -1,12 +1,16 @@
1
import { BrowserRouter, Route, Routes } from "react-router-dom"
2
-import { createElement as h } from 'react'
2
+import { createElement as h, Fragment } from 'react'
3
import { BrowseFiles } from "./BrowseFiles";
4
+import { Dialogs } from './dialog'
5
6
function App() {
6
- return h(BrowserRouter, {},
7
- h(Routes, {},
8
- h(Route, { path:'*', element:h(BrowseFiles) })
9
- )
7
+ return h(Fragment, {},
8
+ h(BrowserRouter, {},
9
+ h(Routes, {},
10
+ h(Route, { path:'*', element:h(BrowseFiles) })
11
+ )
12
+ ),
13
+ h(Dialogs)
14
)
15
}
16
frontend/src/dialog.css
new
+56
@@ -0,0 +1,56 @@
1
+.dialog-backdrop {
2
+ position: absolute;
3
+ top: 0;
4
+ bottom: 0;
5
+ left: 0;
6
+ right: 0;
7
+ background: #888a;
8
+ display: flex;
9
+ justify-Content: center;
10
+ align-Items: center;
11
+}
12
+.dialog {
13
+ background: #fff;
14
+ padding: 1em 1.5em;
15
+ border-radius: 1em;
16
+ position: relative;
17
+ color: var(--color);
18
+ border: 3px solid var(--color);
19
+ margin: 0 1.5em; /* leave space for corner icons */
20
+}
21
+.dialog-icon {
22
+ color: #fff;
23
+ position: absolute;
24
+ width: 1.6em;
25
+ height: 1.6em;
26
+ text-align: center;
27
+ border-radius: 50%;
28
+ opacity: .8;
29
+}
30
+.dialog-closer {
31
+ background: #c77;
32
+ right: -1.4em;
33
+ bottom: -1.4em;
34
+ width: 2.5em;
35
+ height: 2.5em;
36
+ opacity: .9;
37
+}
38
+.dialog-type {
39
+ left: -0.8em;
40
+ top: -0.8em;
41
+ overflow: hidden;
42
+ line-height: 1.7em;
43
+ background-color: var(--color);
44
+}
45
+.dialog-prompt {
46
+ --color: #228
47
+}
48
+.dialog-alert-info {
49
+ --color: #282
50
+}
51
+.dialog-alert-warning {
52
+ --color: #c91
53
+}
54
+.dialog-alert-error {
55
+ --color: #822;
56
+}
frontend/src/dialog.ts
new
+115
@@ -0,0 +1,115 @@
1
+import { createElement as h, Fragment, FunctionComponent, ReactNode, useEffect, useRef } from 'react'
2
+import { proxy, useSnapshot } from 'valtio'
3
+import './dialog.css'
4
+
5
+interface DialogOptions {
6
+ content: string | FunctionComponent,
7
+ closable?: boolean,
8
+ onClose?: (v?:any)=> any,
9
+ className?: string,
10
+ icon?: string | FunctionComponent,
11
+ closableContent?: string | ReactNode
12
+}
13
+
14
+const dialogs = proxy<DialogOptions[]>([])
15
+
16
+export const dialogsDefaults = {
17
+ closableContent: 'x',
18
+}
19
+
20
+export function Dialogs() {
21
+ const snap = useSnapshot(dialogs)
22
+ return h(Fragment, {},
23
+ snap.length > 0 && snap.map((d,i) =>
24
+ h(Dialog, { key:i, ...d })))
25
+}
26
+
27
+function Dialog(d:DialogOptions) {
28
+ useEffect(()=>{
29
+ ref.current?.focus()
30
+ }, [])
31
+ const ref = useRef<HTMLElement>()
32
+ d = { ...dialogsDefaults, ...d }
33
+ return h('div', { ref, className:'dialog-backdrop', tabIndex:0, onKeyDown },
34
+ h('div', { className:'dialog '+(d.className||'') },
35
+ d.closable || d.closable===undefined && h('button', { className:'dialog-icon dialog-closer', onClick:()=> closeDialog() }, d.closableContent),
36
+ d.icon && h('div', { className:'dialog-icon dialog-type' }, d.icon),
37
+ h('div', {}, typeof d.content === 'function' ? h(d.content) : d.content)
38
+ ))
39
+}
40
+
41
+function onKeyDown(ev:any) {
42
+ if (ev.key === 'Escape') {
43
+ closeDialog()
44
+ }
45
+}
46
+
47
+export function newDialog(options: DialogOptions) {
48
+ const $id = Math.random()
49
+ ;(options as any).$id = $id // object identity is not working because of the proxy. This is a possible workaround
50
+ dialogs.push(options)
51
+ return (v?:any) => {
52
+ const i = dialogs.findIndex(x => (x as any).$id === $id)
53
+ if (i < 0) return
54
+ dialogs.splice(i,1)
55
+ options.onClose?.(v)
56
+ }
57
+}
58
+
59
+export function closeDialog(v?:any) {
60
+ dialogs.pop()?.onClose?.(v)
61
+}
62
+
63
+interface PromptOptions { def?:string, type?:string }
64
+export async function promptDialog(msg: string, { def, type }:PromptOptions={}) : Promise<string | null> {
65
+ return new Promise(resolve => newDialog({
66
+ className: 'dialog-prompt',
67
+ icon: '?',
68
+ onClose: resolve,
69
+ content: Content
70
+ }) )
71
+
72
+ function Content() {
73
+ const ref = useRef()
74
+ useEffect(()=>{
75
+ const e = ref.current
76
+ if (!e) return
77
+ const inp = e as HTMLInputElement
78
+ setTimeout(()=> inp.focus(),100)
79
+ if (def)
80
+ inp.value = def
81
+ },[])
82
+ return h('div', {},
83
+ h('div', {}, msg),
84
+ h('input', {
85
+ ref,
86
+ type,
87
+ autoFocus: true,
88
+ onKeyDown(ev: KeyboardEvent) {
89
+ const { key } = ev
90
+ if (key === 'Escape')
91
+ closeDialog(null)
92
+ if (key === 'Enter') {
93
+ closeDialog((ev.target as any).value as string)
94
+ }
95
+ }
96
+ })
97
+ )
98
+ }
99
+}
100
+
101
+type AlertType = 'error' | 'warning' | 'info'
102
+
103
+export async function alertDialog(msg: string | Error, type:AlertType='info') {
104
+ if (msg instanceof Error) {
105
+ msg = String(msg)
106
+ type = 'error'
107
+ }
108
+ return new Promise(resolve => newDialog({
109
+ className: 'dialog-alert-'+type,
110
+ icon: '!',
111
+ onClose: resolve,
112
+ content: String(msg),
113
+ }))
114
+}
115
+
frontend/src/index.scss
+7
-1
@@ -6,12 +6,18 @@ body {
6
-webkit-font-smoothing: antialiased;
7
-moz-osx-font-smoothing: grayscale;
8
}
9
-#root {
9
+#root, input {
10
max-width: 50em;
11
+ color: #555;
12
}
13
code {
14
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
15
}
16
+input {
17
+ padding: 0.3em 0.4em;
18
+ border-radius: 0.5em;
19
+ border-color: #0005;
20
+}
21
22
.icon {
23
vertical-align: text-bottom;
todo.md
+2
@@ -10,6 +10,8 @@
10
- streamable zip archives (no compression, resumable?)
11
- config: max speed (total/per-ip)
12
- config: max connections (total/per-ip)
13
+- user.ignoreLimits
14
+- user.redirect
15
- config: bans
16
- config: min disk space
17
- frontend: dialogs