add shell
This commit is contained in:
parent
aaa60216b2
commit
be417729ea
10 changed files with 768 additions and 380 deletions
230
client-electron/src/pages/android/android.tsx
Normal file
230
client-electron/src/pages/android/android.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useAdb from '../../hooks/useAdb'
|
||||
import { type AdbScrcpyClient } from '@yume-chan/adb-scrcpy'
|
||||
import { type WebCodecsDecoder } from '@yume-chan/scrcpy-decoder-webcodecs'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useBeforeUnload } from 'react-router-dom'
|
||||
import { ToolBar } from './components/tool-bar'
|
||||
import { ScrcpyTab } from './components/scrcpy-tab'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { ShellTab } from './components/shell-tab'
|
||||
|
||||
const AndroidPage: React.FC = () => {
|
||||
const { adb, setAdb } = useAdb(
|
||||
useShallow(state => ({
|
||||
adb: state.adb,
|
||||
manager: state.manager,
|
||||
device: state.device,
|
||||
setAdb: state.setAdb,
|
||||
setDevice: state.setDevice
|
||||
}))
|
||||
)
|
||||
|
||||
const [client, setClient] = useState<AdbScrcpyClient | undefined>()
|
||||
const [decoder, setDecoder] = useState<WebCodecsDecoder | undefined>()
|
||||
|
||||
// when user close or refresh the page, close the adb connection
|
||||
useBeforeUnload(
|
||||
useCallback(() => {
|
||||
decoder?.dispose()
|
||||
client?.close()
|
||||
adb?.close()
|
||||
|
||||
setDecoder(undefined)
|
||||
setClient(undefined)
|
||||
setAdb(undefined)
|
||||
}, [])
|
||||
)
|
||||
|
||||
// async function scrcpyConnect() {
|
||||
// const server: ArrayBuffer = await fetch(new URL('../scrcpy/scrcpy_server_v1.25', import.meta.url)).then(res =>
|
||||
// res.arrayBuffer()
|
||||
// )
|
||||
|
||||
// await AdbScrcpyClient.pushServer(
|
||||
// adb!,
|
||||
// new ReadableStream({
|
||||
// start(controller) {
|
||||
// controller.enqueue(new Consumable(new Uint8Array(server)))
|
||||
// controller.close()
|
||||
// }
|
||||
// })
|
||||
// )
|
||||
|
||||
// const res = await adb!.subprocess.spawn(
|
||||
// 'CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process / com.genymobile.scrcpy.Server 1.25'
|
||||
// )
|
||||
|
||||
// res.stdout.pipeThrough(new DecodeUtf8Stream()).pipeTo(
|
||||
// new WritableStream({
|
||||
// write(chunk) {
|
||||
// console.log(chunk)
|
||||
// }
|
||||
// })
|
||||
// )
|
||||
|
||||
// const scrcpyOption = new ScrcpyOptions1_25({
|
||||
// maxFps: 60,
|
||||
// bitRate: 4000000,
|
||||
// stayAwake: true,
|
||||
// control: true,
|
||||
// logLevel: ScrcpyLogLevel1_18.Debug
|
||||
// })
|
||||
// const _client = await AdbScrcpyClient.start(
|
||||
// adb!,
|
||||
// '/data/local/tmp/scrcpy-server.jar',
|
||||
// '1.25',
|
||||
// new AdbScrcpyOptions1_22(scrcpyOption)
|
||||
// )
|
||||
|
||||
// const videoStream: AdbScrcpyVideoStream | undefined = await _client?.videoStream
|
||||
|
||||
// if (videoStream) {
|
||||
// const _decoder = new WebCodecsDecoder(ScrcpyVideoCodecId.H264)
|
||||
|
||||
// _decoder.renderer.style.width = '100%'
|
||||
// _decoder.renderer.style.height = '100%'
|
||||
|
||||
// if (screenRef.current && screenRef.current.firstChild) {
|
||||
// screenRef.current.removeChild(screenRef.current.firstChild)
|
||||
// }
|
||||
// screenRef.current?.appendChild(_decoder.renderer)
|
||||
// videoStream?.stream.pipeTo(_decoder.writable)
|
||||
// setDecoder(_decoder)
|
||||
|
||||
// if (_client.controlMessageWriter) {
|
||||
// _decoder.renderer.addEventListener('mousedown', e => {
|
||||
// // client width and height 700 x 400
|
||||
// const react = _decoder.renderer.getBoundingClientRect()
|
||||
|
||||
// // normalize to _decoder.renderer.width and height 1080 x 1920
|
||||
// const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width
|
||||
// const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height
|
||||
|
||||
// //console.log('mouse down at ' + x + ' ' + y)
|
||||
// _client.controlMessageWriter?.injectTouch({
|
||||
// action: AndroidMotionEventAction.Down,
|
||||
// pointerId: ScrcpyPointerId.Mouse | ScrcpyPointerId.Finger,
|
||||
// pointerX: x,
|
||||
// pointerY: y,
|
||||
// pressure: 1,
|
||||
// screenWidth: _decoder.renderer.width,
|
||||
// screenHeight: _decoder.renderer.height,
|
||||
// buttons: 0,
|
||||
// actionButton: 0
|
||||
// })
|
||||
// })
|
||||
|
||||
// _decoder.renderer.addEventListener('mouseup', e => {
|
||||
// // client width and height 700 x 400
|
||||
// const react = _decoder.renderer.getBoundingClientRect()
|
||||
|
||||
// // normalize to _decoder.renderer.width and height 1080 x 1920
|
||||
// const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width
|
||||
// const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height
|
||||
|
||||
// //console.log('mouse up at ' + x + ' ' + y)
|
||||
// _client.controlMessageWriter?.injectTouch({
|
||||
// action: AndroidMotionEventAction.Up,
|
||||
// pointerId: ScrcpyPointerId.Mouse,
|
||||
// pointerX: x,
|
||||
// pointerY: y,
|
||||
// pressure: 1,
|
||||
// screenWidth: _decoder.renderer.width,
|
||||
// screenHeight: _decoder.renderer.height,
|
||||
// buttons: 0,
|
||||
// actionButton: 0
|
||||
// })
|
||||
// })
|
||||
|
||||
// _decoder.renderer.addEventListener('mousemove', e => {
|
||||
// // client width and height 700 x 400
|
||||
// const react = _decoder.renderer.getBoundingClientRect()
|
||||
|
||||
// // normalize to _decoder.renderer.width and height 1080 x 1920
|
||||
// const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width
|
||||
// const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height
|
||||
|
||||
// //console.log('mouse move at ' + x + ' ' + y)
|
||||
// _client.controlMessageWriter?.injectTouch({
|
||||
// action: AndroidMotionEventAction.Move,
|
||||
// pointerId: ScrcpyPointerId.Mouse,
|
||||
// pointerX: x,
|
||||
// pointerY: y,
|
||||
// pressure: 1,
|
||||
// screenWidth: _decoder.renderer.width,
|
||||
// screenHeight: _decoder.renderer.height,
|
||||
// buttons: 0,
|
||||
// actionButton: 0
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
// setClient(_client)
|
||||
// }
|
||||
|
||||
// function disconnectAdb() {
|
||||
// client?.close()
|
||||
// adb?.close()
|
||||
// setClient(undefined)
|
||||
// setAdb(undefined)
|
||||
// }
|
||||
|
||||
// function scrcpyDisconnect() {
|
||||
// decoder?.dispose()
|
||||
// client?.close()
|
||||
// if (decoder && screenRef.current) {
|
||||
// screenRef.current.removeChild(decoder.renderer)
|
||||
// }
|
||||
// setClient(undefined)
|
||||
// setDecoder(undefined)
|
||||
// }
|
||||
|
||||
// async function rebootDevice() {
|
||||
// const res = await adb?.power.reboot()
|
||||
// console.log('[rebootDevice] res: ', res)
|
||||
// }
|
||||
|
||||
// function goHome() {
|
||||
// client?.controlMessageWriter?.injectKeyCode({
|
||||
// action: AndroidKeyEventAction.Up,
|
||||
// keyCode: AndroidKeyCode.AndroidHome,
|
||||
// metaState: 0,
|
||||
// repeat: 0
|
||||
// })
|
||||
// }
|
||||
|
||||
// function goBack() {
|
||||
// client?.controlMessageWriter?.injectKeyCode({
|
||||
// action: AndroidKeyEventAction.Up,
|
||||
// keyCode: AndroidKeyCode.AndroidBack,
|
||||
// metaState: 0,
|
||||
// repeat: 0
|
||||
// })
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex w-full p-5">
|
||||
<ToolBar />
|
||||
</div>
|
||||
<Tabs defaultValue="scrcpy" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="scrcpy">Scrcpy</TabsTrigger>
|
||||
<TabsTrigger value="shell">Shell</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="scrcpy">
|
||||
<ScrcpyTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="shell">
|
||||
<ShellTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AndroidPage
|
||||
113
client-electron/src/pages/android/components/scrcpy-tab.tsx
Normal file
113
client-electron/src/pages/android/components/scrcpy-tab.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import useAdb from '@/hooks/useAdb'
|
||||
import { type AdbSubprocessProtocol } from '@yume-chan/adb'
|
||||
import { WritableStream } from '@yume-chan/stream-extra'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
export const ScrcpyTab: React.FC = () => {
|
||||
const adb = useAdb(state => state.adb)
|
||||
|
||||
const logcatRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [process, setProcess] = useState<AdbSubprocessProtocol | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
const startTerminal = async () => {
|
||||
if (logcatRef.current && adb) {
|
||||
if (logcatRef.current.children.length > 0) {
|
||||
// remove all children from the logcatRef
|
||||
while (logcatRef.current.firstChild) {
|
||||
logcatRef.current.removeChild(logcatRef.current.firstChild)
|
||||
}
|
||||
}
|
||||
const terminal: Terminal = new Terminal()
|
||||
const fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
const process: AdbSubprocessProtocol = await adb.subprocess.shell('logcat')
|
||||
process.stdout.pipeTo(
|
||||
new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
terminal.write(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
terminal.options.disableStdin = true
|
||||
terminal.options.theme = {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4'
|
||||
}
|
||||
|
||||
terminal.open(logcatRef.current)
|
||||
fitAddon.fit()
|
||||
setProcess(process)
|
||||
}
|
||||
}
|
||||
startTerminal()
|
||||
|
||||
return () => {
|
||||
logcatRef.current && logcatRef.current.firstChild && logcatRef.current.removeChild(logcatRef.current.firstChild)
|
||||
process?.stderr.cancel()
|
||||
process?.stdout.cancel()
|
||||
process?.kill()
|
||||
}
|
||||
}, [logcatRef, adb])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Scrcpy</CardTitle>
|
||||
<CardDescription>Stream and control your Android device from your computer</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full justify-around items-start">
|
||||
<div>
|
||||
<div className="w-[450px] h-[800px] bg-slate-700" />
|
||||
<div className="flex pt-3 justify-center items-center space-x-4 w-[450px]">
|
||||
<Button variant={'outline'} className="flex-1">
|
||||
Home
|
||||
</Button>
|
||||
<Button variant={'outline'} className="flex-1">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 w-full px-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Control</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex space-x-4 items-center">
|
||||
<Button>Connect</Button>
|
||||
<Button>Power</Button>
|
||||
<Button>Volume Up</Button>
|
||||
<Button>Volume Down</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* logcat card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Logcat</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex space-x-4 items-center">
|
||||
<div className="w-full h-96 bg-slate-700" ref={logcatRef} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button>Save changes</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
100
client-electron/src/pages/android/components/shell-tab.tsx
Normal file
100
client-electron/src/pages/android/components/shell-tab.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import useAdb from '@/hooks/useAdb'
|
||||
import { encodeUtf8, type AdbSubprocessProtocol } from '@yume-chan/adb'
|
||||
import { Consumable, WritableStream } from '@yume-chan/stream-extra'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
export const ShellTab: React.FC = () => {
|
||||
const adb = useAdb(state => state.adb)
|
||||
|
||||
const shellRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [process, setProcess] = useState<AdbSubprocessProtocol | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
const startTerminal = async () => {
|
||||
if (shellRef.current && adb) {
|
||||
if (shellRef.current.children.length > 0) {
|
||||
// remove all children from the shellRef
|
||||
while (shellRef.current.firstChild) {
|
||||
shellRef.current.removeChild(shellRef.current.firstChild)
|
||||
}
|
||||
}
|
||||
const terminal: Terminal = new Terminal()
|
||||
const fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
const process: AdbSubprocessProtocol = await adb.subprocess.shell(
|
||||
'/data/data/com.termux/files/usr/bin/telnet localhost 45515'
|
||||
)
|
||||
process.stdout.pipeTo(
|
||||
new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
terminal.write(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const writer = process.stdin.getWriter()
|
||||
terminal.onData(data => {
|
||||
const buffer = encodeUtf8(data)
|
||||
const consumable = new Consumable(buffer)
|
||||
writer.write(consumable)
|
||||
})
|
||||
|
||||
terminal.options.cursorBlink = true
|
||||
terminal.options.theme = {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4'
|
||||
}
|
||||
|
||||
terminal.open(shellRef.current)
|
||||
fitAddon.fit()
|
||||
setProcess(process)
|
||||
}
|
||||
}
|
||||
startTerminal()
|
||||
|
||||
return () => {
|
||||
console.log('cleaning up shell')
|
||||
shellRef.current && shellRef.current.firstChild && shellRef.current.removeChild(shellRef.current.firstChild)
|
||||
process?.stderr.cancel()
|
||||
process?.stdin.close()
|
||||
process?.stdout.cancel()
|
||||
process?.kill()
|
||||
}
|
||||
}, [shellRef, adb])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Shell</CardTitle>
|
||||
<CardDescription>Access your device's shell using a terminal emulator</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-end py-3 w-full">
|
||||
<div>
|
||||
<Button
|
||||
variant={'destructive'}
|
||||
onClick={() => {
|
||||
process?.stderr.cancel()
|
||||
process?.stdin.close()
|
||||
process?.stdout.cancel()
|
||||
process?.kill()
|
||||
}}
|
||||
>
|
||||
Kill Shell
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-[800px] bg-slate-700" ref={shellRef}></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
158
client-electron/src/pages/android/components/tool-bar.tsx
Normal file
158
client-electron/src/pages/android/components/tool-bar.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import useAdb from '@/hooks/useAdb'
|
||||
import { Adb, AdbDaemonTransport } from '@yume-chan/adb'
|
||||
import AdbWebCredentialStore from '@yume-chan/adb-credential-web'
|
||||
import { ADB_DEFAULT_DEVICE_FILTER } from '@yume-chan/adb-daemon-webusb'
|
||||
import { useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
export const ToolBar: React.FC = () => {
|
||||
const { manager, device, adb, setDevice, setAdb } = useAdb(
|
||||
useShallow(state => ({
|
||||
manager: state.manager,
|
||||
device: state.device,
|
||||
adb: state.adb,
|
||||
setDevice: state.setDevice,
|
||||
setAdb: state.setAdb
|
||||
}))
|
||||
)
|
||||
|
||||
const [name, setName] = useState<string>('')
|
||||
const [resolution, setResolution] = useState<string>('')
|
||||
const [version, setVersion] = useState<string>('')
|
||||
|
||||
async function createNewConnection() {
|
||||
let selectedDevice
|
||||
|
||||
if (!device) {
|
||||
selectedDevice = await manager?.requestDevice({
|
||||
filters: [
|
||||
{
|
||||
...ADB_DEFAULT_DEVICE_FILTER,
|
||||
serialNumber: 'd'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if (!selectedDevice) {
|
||||
return
|
||||
} else {
|
||||
setDevice(selectedDevice)
|
||||
}
|
||||
} else {
|
||||
selectedDevice = device
|
||||
}
|
||||
|
||||
let connection
|
||||
try {
|
||||
connection = await selectedDevice.connect()
|
||||
} catch (e) {
|
||||
toast({
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to connect to device',
|
||||
description: (e as Error).message
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const credentialStore: AdbWebCredentialStore = new AdbWebCredentialStore()
|
||||
|
||||
const transport = await AdbDaemonTransport.authenticate({
|
||||
serial: selectedDevice.serial,
|
||||
connection: connection,
|
||||
credentialStore: credentialStore
|
||||
})
|
||||
|
||||
const adb: Adb = new Adb(transport)
|
||||
|
||||
const name = await adb.getProp('ro.product.model')
|
||||
const version = await adb.getProp('ro.build.version.release')
|
||||
|
||||
setName(name)
|
||||
setResolution(resolution)
|
||||
setVersion(version)
|
||||
|
||||
setAdb(adb)
|
||||
}
|
||||
|
||||
function onDisconnect() {
|
||||
device?.raw.forget()
|
||||
setDevice(undefined)
|
||||
|
||||
adb?.close()
|
||||
setAdb(undefined)
|
||||
}
|
||||
|
||||
function onTerminate() {
|
||||
adb?.close()
|
||||
setAdb(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center space-x-5 w-full p-4 shadow-lg rounded-lg">
|
||||
{adb ? (
|
||||
<div className="flex flex-col justify-center items-start">
|
||||
<ul className="list-disc pl-4">
|
||||
<li>Name: {name}</li>
|
||||
<li>Version: {version}</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col justify-center items-start">
|
||||
<h2>No Device Connected</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-4">
|
||||
{adb ? (
|
||||
<DisconnectConfirmDialog onDisconnect={onDisconnect} onTerminate={onTerminate} />
|
||||
) : (
|
||||
<Button variant={'default'} onClick={createNewConnection}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DisconnectConfirmDialogProps {
|
||||
onDisconnect: () => void
|
||||
onTerminate: () => void
|
||||
}
|
||||
|
||||
const DisconnectConfirmDialog: React.FC<DisconnectConfirmDialogProps> = ({ onDisconnect, onTerminate }) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'destructive'}>Disconnect</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disconnect Device</DialogTitle>
|
||||
<DialogDescription>
|
||||
Do you want to also declaim device? if so press Disconnect else press Terminate
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={onDisconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onTerminate}>
|
||||
Terminate
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue