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