migrated from redux to recoil
This commit is contained in:
@@ -13,11 +13,10 @@
|
|||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/icons-material": "^5.11.16",
|
"@mui/icons-material": "^5.11.16",
|
||||||
"@mui/material": "^5.13.5",
|
"@mui/material": "^5.13.5",
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-redux": "^8.1.1",
|
|
||||||
"react-router-dom": "^6.13.0",
|
"react-router-dom": "^6.13.0",
|
||||||
|
"recoil": "^0.7.7",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
@@ -28,9 +27,9 @@
|
|||||||
"@types/react-dom": "^18.2.6",
|
"@types/react-dom": "^18.2.6",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/uuid": "^9.0.2",
|
"@types/uuid": "^9.0.2",
|
||||||
"@vitejs/plugin-react": "^4.0.1",
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"typescript": "^5.1.3",
|
"typescript": "^5.1.3",
|
||||||
"vite": "^4.3.9"
|
"vite": "^4.4.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Provider } from 'react-redux'
|
|
||||||
import { RouterProvider } from 'react-router-dom'
|
import { RouterProvider } from 'react-router-dom'
|
||||||
|
import { RecoilRoot } from 'recoil'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import { store } from './stores/store'
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<RecoilRoot>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</Provider>
|
</RecoilRoot>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ThemeProvider } from '@emotion/react'
|
import { ThemeProvider } from '@emotion/react'
|
||||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||||
import Dashboard from '@mui/icons-material/Dashboard'
|
import Dashboard from '@mui/icons-material/Dashboard'
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
import Menu from '@mui/icons-material/Menu'
|
import Menu from '@mui/icons-material/Menu'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
|
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
|
||||||
import Storage from '@mui/icons-material/Storage'
|
import Storage from '@mui/icons-material/Storage'
|
||||||
import { Box, createTheme } from '@mui/material'
|
import { Box, createTheme } from '@mui/material'
|
||||||
import DownloadIcon from '@mui/icons-material/Download'
|
|
||||||
import CssBaseline from '@mui/material/CssBaseline'
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from '@mui/material/IconButton'
|
||||||
@@ -18,23 +18,23 @@ import Toolbar from '@mui/material/Toolbar'
|
|||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { grey } from '@mui/material/colors'
|
import { grey } from '@mui/material/colors'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
|
||||||
import { Link, Outlet } from 'react-router-dom'
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
import { RootState } from './stores/store'
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { settingsState } from './atoms/settings'
|
||||||
|
import { statusState } from './atoms/status'
|
||||||
import AppBar from './components/AppBar'
|
import AppBar from './components/AppBar'
|
||||||
import Drawer from './components/Drawer'
|
import Drawer from './components/Drawer'
|
||||||
import Logout from './components/Logout'
|
import Logout from './components/Logout'
|
||||||
import ThemeToggler from './components/ThemeToggler'
|
import ThemeToggler from './components/ThemeToggler'
|
||||||
import Toaster from './providers/ToasterProvider'
|
import Toaster from './providers/ToasterProvider'
|
||||||
import I18nProvider from './providers/i18nProvider'
|
|
||||||
import RPCClientProvider from './providers/rpcClientProvider'
|
|
||||||
import { formatGiB } from './utils'
|
import { formatGiB } from './utils'
|
||||||
|
import SocketSubscriber from './components/SocketSubscriber'
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const settings = useRecoilValue(settingsState)
|
||||||
const status = useSelector((state: RootState) => state.status)
|
const status = useRecoilValue(statusState)
|
||||||
|
|
||||||
const mode = settings.theme
|
const mode = settings.theme
|
||||||
const theme = useMemo(() =>
|
const theme = useMemo(() =>
|
||||||
@@ -54,132 +54,130 @@ export default function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<I18nProvider>
|
<SocketSubscriber>
|
||||||
<RPCClientProvider>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<Box sx={{ display: 'flex' }}>
|
<CssBaseline />
|
||||||
<CssBaseline />
|
<AppBar position="absolute" open={open}>
|
||||||
<AppBar position="absolute" open={open}>
|
<Toolbar sx={{ pr: '24px' }}>
|
||||||
<Toolbar sx={{ pr: '24px' }}>
|
<IconButton
|
||||||
<IconButton
|
edge="start"
|
||||||
edge="start"
|
color="inherit"
|
||||||
color="inherit"
|
aria-label="open drawer"
|
||||||
aria-label="open drawer"
|
onClick={toggleDrawer}
|
||||||
onClick={toggleDrawer}
|
|
||||||
sx={{
|
|
||||||
marginRight: '36px',
|
|
||||||
...(open && { display: 'none' }),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu />
|
|
||||||
</IconButton>
|
|
||||||
<Typography
|
|
||||||
component="h1"
|
|
||||||
variant="h6"
|
|
||||||
color="inherit"
|
|
||||||
noWrap
|
|
||||||
sx={{ flexGrow: 1 }}
|
|
||||||
>
|
|
||||||
yt-dlp WebUI
|
|
||||||
</Typography>
|
|
||||||
{
|
|
||||||
status.freeSpace ?
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}>
|
|
||||||
<Storage />
|
|
||||||
<span>
|
|
||||||
{formatGiB(status.freeSpace)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}>
|
|
||||||
<SettingsEthernet />
|
|
||||||
<span>
|
|
||||||
{status.connected ? settings.serverAddr : 'not connected'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
<Drawer variant="permanent" open={open}>
|
|
||||||
<Toolbar
|
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
marginRight: '36px',
|
||||||
alignItems: 'center',
|
...(open && { display: 'none' }),
|
||||||
justifyContent: 'flex-end',
|
|
||||||
px: [1],
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton onClick={toggleDrawer}>
|
<Menu />
|
||||||
<ChevronLeft />
|
</IconButton>
|
||||||
</IconButton>
|
<Typography
|
||||||
</Toolbar>
|
component="h1"
|
||||||
<Divider />
|
variant="h6"
|
||||||
<List component="nav">
|
color="inherit"
|
||||||
<Link to={'/'} style={
|
noWrap
|
||||||
{
|
sx={{ flexGrow: 1 }}
|
||||||
textDecoration: 'none',
|
>
|
||||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
yt-dlp WebUI
|
||||||
}
|
</Typography>
|
||||||
}>
|
{
|
||||||
<ListItemButton disabled={status.downloading}>
|
status.freeSpace ?
|
||||||
<ListItemIcon>
|
<div style={{
|
||||||
<Dashboard />
|
display: 'flex',
|
||||||
</ListItemIcon>
|
alignItems: 'center',
|
||||||
<ListItemText primary="Home" />
|
flexWrap: 'wrap',
|
||||||
</ListItemButton>
|
}}>
|
||||||
</Link>
|
<Storage />
|
||||||
<Link to={'/archive'} style={
|
<span>
|
||||||
{
|
{formatGiB(status.freeSpace)}
|
||||||
textDecoration: 'none',
|
</span>
|
||||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
</div>
|
||||||
}
|
: null
|
||||||
}>
|
}
|
||||||
<ListItemButton disabled={status.downloading}>
|
<div style={{
|
||||||
<ListItemIcon>
|
display: 'flex',
|
||||||
<DownloadIcon />
|
alignItems: 'center',
|
||||||
</ListItemIcon>
|
flexWrap: 'wrap',
|
||||||
<ListItemText primary="Archive" />
|
}}>
|
||||||
</ListItemButton>
|
<SettingsEthernet />
|
||||||
</Link>
|
<span>
|
||||||
<Link to={'/settings'} style={
|
{status.connected ? settings.serverAddr : 'not connected'}
|
||||||
{
|
</span>
|
||||||
textDecoration: 'none',
|
</div>
|
||||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
</Toolbar>
|
||||||
}
|
</AppBar>
|
||||||
}>
|
<Drawer variant="permanent" open={open}>
|
||||||
<ListItemButton disabled={status.downloading}>
|
<Toolbar
|
||||||
<ListItemIcon>
|
|
||||||
<SettingsIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Settings" />
|
|
||||||
</ListItemButton>
|
|
||||||
</Link>
|
|
||||||
<ThemeToggler />
|
|
||||||
<Logout />
|
|
||||||
</List>
|
|
||||||
</Drawer>
|
|
||||||
<Box
|
|
||||||
component="main"
|
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1,
|
display: 'flex',
|
||||||
height: '100vh',
|
alignItems: 'center',
|
||||||
overflow: 'auto',
|
justifyContent: 'flex-end',
|
||||||
|
px: [1],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar />
|
<IconButton onClick={toggleDrawer}>
|
||||||
<Outlet />
|
<ChevronLeft />
|
||||||
</Box>
|
</IconButton>
|
||||||
|
</Toolbar>
|
||||||
|
<Divider />
|
||||||
|
<List component="nav">
|
||||||
|
<Link to={'/'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Dashboard />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Home" />
|
||||||
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
|
<Link to={'/archive'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DownloadIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Archive" />
|
||||||
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
|
<Link to={'/settings'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SettingsIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Settings" />
|
||||||
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
|
<ThemeToggler />
|
||||||
|
<Logout />
|
||||||
|
</List>
|
||||||
|
</Drawer>
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar />
|
||||||
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
<Toaster />
|
</Box>
|
||||||
</RPCClientProvider>
|
<Toaster />
|
||||||
</I18nProvider>
|
</SocketSubscriber>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
9
frontend/src/atoms/downloadTemplate.ts
Normal file
9
frontend/src/atoms/downloadTemplate.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { atom } from 'recoil'
|
||||||
|
|
||||||
|
export const downloadTemplateState = atom({
|
||||||
|
key: 'downloadTemplateState',
|
||||||
|
default: localStorage.getItem('lastDownloadTemplate') ?? '',
|
||||||
|
effects: [
|
||||||
|
({ onSet }) => onSet(e => localStorage.setItem('lastDownloadTemplate', e))
|
||||||
|
]
|
||||||
|
})
|
||||||
7
frontend/src/atoms/downloads.ts
Normal file
7
frontend/src/atoms/downloads.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { atom } from 'recoil'
|
||||||
|
import { RPCResult } from '../types'
|
||||||
|
|
||||||
|
export const activeDownloadsState = atom<RPCResult[] | undefined>({
|
||||||
|
key: 'activeDownloadsState',
|
||||||
|
default: undefined
|
||||||
|
})
|
||||||
7
frontend/src/atoms/format.ts
Normal file
7
frontend/src/atoms/format.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { atom } from 'recoil'
|
||||||
|
import { DLMetadata } from '../types'
|
||||||
|
|
||||||
|
export const selectedFormatState = atom<Partial<DLMetadata>>({
|
||||||
|
key: 'selectedFormatState',
|
||||||
|
default: {},
|
||||||
|
})
|
||||||
9
frontend/src/atoms/i18n.ts
Normal file
9
frontend/src/atoms/i18n.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { selector } from 'recoil'
|
||||||
|
import I18nBuilder from '../lib/intl'
|
||||||
|
import { languageState } from './settings'
|
||||||
|
|
||||||
|
export const i18nBuilderState = selector({
|
||||||
|
key: 'i18nBuilderState',
|
||||||
|
get: ({ get }) => new I18nBuilder(get(languageState)),
|
||||||
|
dangerouslyAllowMutability: true,
|
||||||
|
})
|
||||||
12
frontend/src/atoms/rpc.ts
Normal file
12
frontend/src/atoms/rpc.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { selector } from 'recoil'
|
||||||
|
import { RPCClient } from '../lib/rpcClient'
|
||||||
|
import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings'
|
||||||
|
|
||||||
|
export const rpcClientState = selector({
|
||||||
|
key: 'rpcClientState',
|
||||||
|
get: ({ get }) =>
|
||||||
|
new RPCClient(get(rpcHTTPEndpoint), get(rpcWebSocketEndpoint)),
|
||||||
|
set: ({ get }) =>
|
||||||
|
new RPCClient(get(rpcHTTPEndpoint), get(rpcWebSocketEndpoint)),
|
||||||
|
dangerouslyAllowMutability: true,
|
||||||
|
})
|
||||||
175
frontend/src/atoms/settings.ts
Normal file
175
frontend/src/atoms/settings.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { atom, selector } from 'recoil'
|
||||||
|
|
||||||
|
export type Language =
|
||||||
|
| 'english'
|
||||||
|
| 'chinese'
|
||||||
|
| 'russian'
|
||||||
|
| 'italian'
|
||||||
|
| 'spanish'
|
||||||
|
| 'korean'
|
||||||
|
| 'japanese'
|
||||||
|
| 'catalan'
|
||||||
|
| 'ukrainian'
|
||||||
|
| 'polish'
|
||||||
|
|
||||||
|
export const languages = [
|
||||||
|
'english',
|
||||||
|
'chinese',
|
||||||
|
'russian',
|
||||||
|
'italian',
|
||||||
|
'spanish',
|
||||||
|
'korean',
|
||||||
|
'japanese',
|
||||||
|
'catalan',
|
||||||
|
'ukrainian',
|
||||||
|
'polish',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
export interface SettingsState {
|
||||||
|
serverAddr: string
|
||||||
|
serverPort: number
|
||||||
|
language: Language
|
||||||
|
theme: Theme
|
||||||
|
cliArgs: string
|
||||||
|
formatSelection: boolean
|
||||||
|
fileRenaming: boolean
|
||||||
|
pathOverriding: boolean
|
||||||
|
enableCustomArgs: boolean
|
||||||
|
listView: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageState = atom<Language>({
|
||||||
|
key: 'languageState',
|
||||||
|
default: localStorage.getItem('language') as Language ?? 'english',
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(l => localStorage.setItem('language', l.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const themeState = atom<Theme>({
|
||||||
|
key: 'themeStateState',
|
||||||
|
default: localStorage.getItem('theme') as Theme ?? 'system',
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(l => localStorage.setItem('theme', l.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const serverAddressState = atom<string>({
|
||||||
|
key: 'serverAddressState',
|
||||||
|
default: localStorage.getItem('server-addr') ?? window.location.hostname,
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('server-addr', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const serverPortState = atom<number>({
|
||||||
|
key: 'serverPortState',
|
||||||
|
default: Number(localStorage.getItem('server-port')) ??
|
||||||
|
Number(window.location.port),
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('server-port', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const latestCliArgumentsState = atom<string>({
|
||||||
|
key: 'latestCliArgumentsState',
|
||||||
|
default: localStorage.getItem('cli-args') ?? '',
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('cli-args', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const formatSelectionState = atom({
|
||||||
|
key: 'formatSelectionState',
|
||||||
|
default: localStorage.getItem('format-selection') === "true",
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('format-selection', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const fileRenamingState = atom({
|
||||||
|
key: 'fileRenamingState',
|
||||||
|
default: localStorage.getItem('file-renaming') === "true",
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('file-renaming', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const pathOverridingState = atom({
|
||||||
|
key: 'pathOverridingState',
|
||||||
|
default: localStorage.getItem('path-overriding') === "true",
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('path-overriding', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const enableCustomArgsState = atom({
|
||||||
|
key: 'enableCustomArgsState',
|
||||||
|
default: localStorage.getItem('enable-custom-args') === "true",
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('enable-custom-args', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const listViewState = atom({
|
||||||
|
key: 'listViewState',
|
||||||
|
default: localStorage.getItem('listview') === "true",
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('listview', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const serverAddressAndPortState = selector({
|
||||||
|
key: 'serverAddressAndPortState',
|
||||||
|
get: ({ get }) => `${get(serverAddressState)}:${get(serverPortState)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
export const serverURL = selector({
|
||||||
|
key: 'serverURL',
|
||||||
|
get: ({ get }) =>
|
||||||
|
`${window.location.protocol}//${get(serverAddressState)}:${get(serverPortState)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
export const rpcWebSocketEndpoint = selector({
|
||||||
|
key: 'rpcWebSocketEndpoint',
|
||||||
|
get: ({ get }) => {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const rpcHTTPEndpoint = selector({
|
||||||
|
key: 'rpcHTTPEndpoint',
|
||||||
|
get: ({ get }) => {
|
||||||
|
const proto = window.location.protocol
|
||||||
|
return `${proto}//${get(serverAddressAndPortState)}/rpc/http`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const settingsState = selector<SettingsState>({
|
||||||
|
key: 'settingsState',
|
||||||
|
get: ({ get }) => ({
|
||||||
|
serverAddr: get(serverAddressState),
|
||||||
|
serverPort: get(serverPortState),
|
||||||
|
language: get(languageState),
|
||||||
|
theme: get(themeState),
|
||||||
|
cliArgs: get(latestCliArgumentsState),
|
||||||
|
formatSelection: get(formatSelectionState),
|
||||||
|
fileRenaming: get(fileRenamingState),
|
||||||
|
pathOverriding: get(pathOverridingState),
|
||||||
|
enableCustomArgs: get(enableCustomArgsState),
|
||||||
|
listView: get(listViewState),
|
||||||
|
})
|
||||||
|
})
|
||||||
39
frontend/src/atoms/status.ts
Normal file
39
frontend/src/atoms/status.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { atom, selector } from 'recoil'
|
||||||
|
|
||||||
|
type StatusState = {
|
||||||
|
connected: boolean,
|
||||||
|
updated: boolean,
|
||||||
|
downloading: boolean,
|
||||||
|
freeSpace: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const connectedState = atom({
|
||||||
|
key: 'connectedState',
|
||||||
|
default: false
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updatedBinaryState = atom({
|
||||||
|
key: 'updatedBinaryState',
|
||||||
|
default: false
|
||||||
|
})
|
||||||
|
|
||||||
|
export const isDownloadingState = atom({
|
||||||
|
key: 'isDownloadingState',
|
||||||
|
default: false
|
||||||
|
})
|
||||||
|
|
||||||
|
export const freeSpaceBytesState = atom({
|
||||||
|
key: 'freeSpaceBytesState',
|
||||||
|
default: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
export const statusState = selector<StatusState>({
|
||||||
|
key: 'statusState',
|
||||||
|
get: ({ get }) => ({
|
||||||
|
connected: get(connectedState),
|
||||||
|
updated: get(updatedBinaryState),
|
||||||
|
downloading: get(isDownloadingState),
|
||||||
|
freeSpace: get(freeSpaceBytesState),
|
||||||
|
})
|
||||||
|
})
|
||||||
15
frontend/src/atoms/toast.ts
Normal file
15
frontend/src/atoms/toast.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { AlertColor } from '@mui/material'
|
||||||
|
import { atom } from 'recoil'
|
||||||
|
|
||||||
|
type Toast = {
|
||||||
|
open: boolean,
|
||||||
|
message: string
|
||||||
|
autoClose: boolean
|
||||||
|
createdAt: number,
|
||||||
|
severity?: AlertColor
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toastListState = atom<Toast[]>({
|
||||||
|
key: 'toastListState',
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardActionArea,
|
|
||||||
CardContent,
|
|
||||||
CardMedia,
|
|
||||||
Skeleton,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material'
|
|
||||||
import { ellipsis } from '../utils'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
title: string,
|
|
||||||
thumbnail: string,
|
|
||||||
url: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArchiveResult({ title, thumbnail, url }: Props) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardActionArea onClick={() => window.open(url)}>
|
|
||||||
{thumbnail ?
|
|
||||||
<CardMedia
|
|
||||||
component="img"
|
|
||||||
height={180}
|
|
||||||
image={thumbnail}
|
|
||||||
/> :
|
|
||||||
<Skeleton variant="rectangular" height={180} />
|
|
||||||
}
|
|
||||||
<CardContent>
|
|
||||||
<Typography gutterBottom variant="body2" component="div">
|
|
||||||
{ellipsis(title, 72)}
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</CardActionArea>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -26,19 +26,19 @@ import { TransitionProps } from '@mui/material/transitions'
|
|||||||
import { Buffer } from 'buffer'
|
import { Buffer } from 'buffer'
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useContext,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
|
import { settingsState } from '../atoms/settings'
|
||||||
|
import { connectedState } from '../atoms/status'
|
||||||
import FormatsGrid from '../components/FormatsGrid'
|
import FormatsGrid from '../components/FormatsGrid'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { useRPC } from '../hooks/useRPC'
|
||||||
import { CliArguments } from '../lib/argsParser'
|
import { CliArguments } from '../lib/argsParser'
|
||||||
import { I18nContext } from '../providers/i18nProvider'
|
|
||||||
import { RPCClientContext } from '../providers/rpcClientProvider'
|
|
||||||
import { RootState } from '../stores/store'
|
|
||||||
import type { DLMetadata } from '../types'
|
import type { DLMetadata } from '../types'
|
||||||
import { isValidURL, toFormatArgs } from '../utils'
|
import { isValidURL, toFormatArgs } from '../utils'
|
||||||
|
|
||||||
@@ -62,9 +62,9 @@ export default function DownloadDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onDownloadStart
|
onDownloadStart
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// redux state
|
// recoil state
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const settings = useRecoilValue(settingsState)
|
||||||
const status = useSelector((state: RootState) => state.status)
|
const [isConnected] = useRecoilState(connectedState)
|
||||||
|
|
||||||
// ephemeral state
|
// ephemeral state
|
||||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||||
@@ -85,11 +85,12 @@ export default function DownloadDialog({
|
|||||||
|
|
||||||
// memos
|
// memos
|
||||||
const cliArgs = useMemo(() =>
|
const cliArgs = useMemo(() =>
|
||||||
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
|
||||||
|
)
|
||||||
|
|
||||||
// context
|
// context
|
||||||
const { i18n } = useContext(I18nContext)
|
const { i18n } = useI18n()
|
||||||
const { client } = useContext(RPCClientContext)
|
const { client } = useRPC()
|
||||||
|
|
||||||
// refs
|
// refs
|
||||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -254,7 +255,7 @@ export default function DownloadDialog({
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
onChange={handleUrlChange}
|
onChange={handleUrlChange}
|
||||||
disabled={
|
disabled={
|
||||||
!status.connected
|
!isConnected
|
||||||
|| (settings.formatSelection && downloadFormats != null)
|
|| (settings.formatSelection && downloadFormats != null)
|
||||||
}
|
}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -290,7 +291,10 @@ export default function DownloadDialog({
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
onChange={handleCustomArgsChange}
|
onChange={handleCustomArgsChange}
|
||||||
value={customArgs}
|
value={customArgs}
|
||||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
disabled={
|
||||||
|
!isConnected ||
|
||||||
|
(settings.formatSelection && downloadFormats != null)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
@@ -304,7 +308,10 @@ export default function DownloadDialog({
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={fileNameOverride}
|
value={fileNameOverride}
|
||||||
onChange={handleFilenameOverrideChange}
|
onChange={handleFilenameOverrideChange}
|
||||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
disabled={
|
||||||
|
!isConnected ||
|
||||||
|
(settings.formatSelection && downloadFormats != null)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
@@ -338,7 +345,11 @@ export default function DownloadDialog({
|
|||||||
: sendUrl()
|
: sendUrl()
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{settings.formatSelection ? i18n.t('selectFormatButton') : i18n.t('startButton')}
|
{
|
||||||
|
settings.formatSelection
|
||||||
|
? i18n.t('selectFormatButton')
|
||||||
|
: i18n.t('startButton')
|
||||||
|
}
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item>
|
<Grid item>
|
||||||
|
|||||||
72
frontend/src/components/Downloads.tsx
Normal file
72
frontend/src/components/Downloads.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
|
import { activeDownloadsState } from '../atoms/downloads'
|
||||||
|
import { listViewState } from '../atoms/settings'
|
||||||
|
import { useRPC } from '../hooks/useRPC'
|
||||||
|
import { DownloadsCardView } from './DownloadsCardView'
|
||||||
|
import { DownloadsListView } from './DownloadsListView'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { connectedState, isDownloadingState } from '../atoms/status'
|
||||||
|
import { datetimeCompareFunc, isRPCResponse } from '../utils'
|
||||||
|
import { RPCResponse, RPCResult } from '../types'
|
||||||
|
|
||||||
|
const Downloads: React.FC = () => {
|
||||||
|
const [active, setActive] = useRecoilState(activeDownloadsState)
|
||||||
|
const isConnected = useRecoilValue(connectedState)
|
||||||
|
const listView = useRecoilValue(listViewState)
|
||||||
|
|
||||||
|
const { client, socket$ } = useRPC()
|
||||||
|
|
||||||
|
const abort = (id?: string) => {
|
||||||
|
if (id) {
|
||||||
|
client.kill(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.killAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnected) { return }
|
||||||
|
|
||||||
|
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
|
||||||
|
if (!isRPCResponse(event)) { return }
|
||||||
|
if (!Array.isArray(event.result)) { return }
|
||||||
|
|
||||||
|
setActive(
|
||||||
|
(event.result || [])
|
||||||
|
.filter(f => !!f.info.url)
|
||||||
|
.sort((a, b) => datetimeCompareFunc(
|
||||||
|
b.info.created_at,
|
||||||
|
a.info.created_at,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => sub.unsubscribe()
|
||||||
|
}, [socket$, isConnected])
|
||||||
|
|
||||||
|
const [, setIsDownloading] = useRecoilState(isDownloadingState)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) {
|
||||||
|
setIsDownloading(true)
|
||||||
|
}
|
||||||
|
}, [active?.length])
|
||||||
|
|
||||||
|
if (listView) {
|
||||||
|
return (
|
||||||
|
<DownloadsListView
|
||||||
|
downloads={active ?? []}
|
||||||
|
onStop={abort}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DownloadsCardView
|
||||||
|
downloads={active ?? []}
|
||||||
|
onStop={abort}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Downloads
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Grid } from "@mui/material"
|
import { Grid } from "@mui/material"
|
||||||
import { Fragment, useContext } from "react"
|
import { Fragment } from "react"
|
||||||
import { useToast } from "../hooks/toast"
|
import { useToast } from "../hooks/toast"
|
||||||
import { I18nContext } from "../providers/i18nProvider"
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import type { RPCResult } from "../types"
|
import type { RPCResult } from "../types"
|
||||||
import { StackableResult } from "./StackableResult"
|
import { StackableResult } from "./StackableResult"
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadsCardView({ downloads, onStop }: Props) {
|
export function DownloadsCardView({ downloads, onStop }: Props) {
|
||||||
const { i18n } = useContext(I18nContext)
|
const { i18n } = useI18n()
|
||||||
const { pushMessage } = useToast()
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
51
frontend/src/components/HomeSpeedDial.tsx
Normal file
51
frontend/src/components/HomeSpeedDial.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||||
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
|
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||||
|
import {
|
||||||
|
SpeedDial,
|
||||||
|
SpeedDialAction,
|
||||||
|
SpeedDialIcon
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useRecoilState } from 'recoil'
|
||||||
|
import { listViewState } from '../atoms/settings'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { useRPC } from '../hooks/useRPC'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onOpen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomeSpeedDial: React.FC<Props> = ({ onOpen }) => {
|
||||||
|
const [, setListView] = useRecoilState(listViewState)
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { client } = useRPC()
|
||||||
|
|
||||||
|
const abort = () => client.killAll()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpeedDial
|
||||||
|
ariaLabel="Home speed dial"
|
||||||
|
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
||||||
|
icon={<SpeedDialIcon />}
|
||||||
|
>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<FormatListBulleted />}
|
||||||
|
tooltipTitle={`Table view`}
|
||||||
|
onClick={() => setListView(state => !state)}
|
||||||
|
/>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<DeleteForeverIcon />}
|
||||||
|
tooltipTitle={i18n.t('abortAllButton')}
|
||||||
|
onClick={abort}
|
||||||
|
/>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<AddCircleIcon />}
|
||||||
|
tooltipTitle={`New download`}
|
||||||
|
onClick={onOpen}
|
||||||
|
/>
|
||||||
|
</SpeedDial>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomeSpeedDial
|
||||||
46
frontend/src/components/SocketSubscriber.tsx
Normal file
46
frontend/src/components/SocketSubscriber.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
|
import { connectedState } from '../atoms/status'
|
||||||
|
import { useRPC } from '../hooks/useRPC'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { serverAddressAndPortState } from '../atoms/settings'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
|
||||||
|
interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
|
||||||
|
|
||||||
|
const SocketSubscriber: React.FC<Props> = ({ children }) => {
|
||||||
|
const [isConnected, setIsConnected] = useRecoilState(connectedState)
|
||||||
|
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { socket$ } = useRPC()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isConnected) { return }
|
||||||
|
|
||||||
|
const sub = socket$.subscribe({
|
||||||
|
next: () => {
|
||||||
|
setIsConnected(true)
|
||||||
|
pushMessage(
|
||||||
|
`Connected to (${serverAddressAndPort})`,
|
||||||
|
"success"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
console.error(e)
|
||||||
|
pushMessage(
|
||||||
|
`${i18n.t('rpcConnErr')} (${serverAddressAndPort})`,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => sub.unsubscribe()
|
||||||
|
}, [isConnected])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>{children}</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SocketSubscriber
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||||
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
||||||
import { useContext } from 'react'
|
import { useRecoilValue } from 'recoil'
|
||||||
import { I18nContext } from '../providers/i18nProvider'
|
import { activeDownloadsState } from '../atoms/downloads'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
|
||||||
const FlexContainer = styled(Container)({
|
const FlexContainer = styled(Container)({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -21,7 +22,12 @@ const Title = styled(Typography)({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default function Splash() {
|
export default function Splash() {
|
||||||
const { i18n } = useContext(I18nContext)
|
const { i18n } = useI18n()
|
||||||
|
const activeDownloads = useRecoilValue(activeDownloadsState)
|
||||||
|
|
||||||
|
if (!activeDownloads || activeDownloads.length !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlexContainer>
|
<FlexContainer>
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
|
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
|
||||||
import { setTheme } from '../features/settings/settingsSlice'
|
|
||||||
import { RootState } from '../stores/store'
|
|
||||||
import { Brightness4, Brightness5 } from '@mui/icons-material'
|
import { Brightness4, Brightness5 } from '@mui/icons-material'
|
||||||
|
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
|
||||||
|
import { useRecoilState } from 'recoil'
|
||||||
|
import { themeState } from '../atoms/settings'
|
||||||
|
|
||||||
export default function ThemeToggler() {
|
export default function ThemeToggler() {
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const [theme, setTheme] = useRecoilState(themeState)
|
||||||
const dispatch = useDispatch()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItemButton onClick={() => {
|
<ListItemButton onClick={() => {
|
||||||
settings.theme === 'light'
|
theme === 'light'
|
||||||
? dispatch(setTheme('dark'))
|
? setTheme('dark')
|
||||||
: dispatch(setTheme('light'))
|
: setTheme('light')
|
||||||
}}>
|
}}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
{
|
{
|
||||||
settings.theme === 'light'
|
theme === 'light'
|
||||||
? <Brightness4 />
|
? <Brightness4 />
|
||||||
: <Brightness5 />
|
: <Brightness5 />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
|
||||||
|
|
||||||
export interface FormatSelectionState {
|
|
||||||
bestFormat: string
|
|
||||||
audioFormat: string
|
|
||||||
videoFormat: string
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
|
||||||
|
|
||||||
export type LanguageUnion =
|
|
||||||
| "english"
|
|
||||||
| "chinese"
|
|
||||||
| "russian"
|
|
||||||
| "italian"
|
|
||||||
| "spanish"
|
|
||||||
| "korean"
|
|
||||||
| "japanese"
|
|
||||||
| "catalan"
|
|
||||||
| "ukrainian"
|
|
||||||
| "polish"
|
|
||||||
|
|
||||||
export type ThemeUnion = "light" | "dark"
|
|
||||||
|
|
||||||
export interface SettingsState {
|
|
||||||
serverAddr: string
|
|
||||||
serverPort: string
|
|
||||||
language: LanguageUnion
|
|
||||||
theme: ThemeUnion
|
|
||||||
cliArgs: string
|
|
||||||
formatSelection: boolean
|
|
||||||
ratelimit: string
|
|
||||||
fileRenaming: boolean
|
|
||||||
pathOverriding: boolean
|
|
||||||
enableCustomArgs: boolean
|
|
||||||
listView: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: SettingsState = {
|
|
||||||
serverAddr: localStorage.getItem("server-addr") || window.location.hostname,
|
|
||||||
serverPort: localStorage.getItem("server-port") || window.location.port,
|
|
||||||
language: (localStorage.getItem("language") || "english") as LanguageUnion,
|
|
||||||
theme: (localStorage.getItem("theme") || "light") as ThemeUnion,
|
|
||||||
cliArgs: localStorage.getItem("cli-args") ?? "",
|
|
||||||
formatSelection: localStorage.getItem("format-selection") === "true",
|
|
||||||
ratelimit: localStorage.getItem("rate-limit") ?? "",
|
|
||||||
fileRenaming: localStorage.getItem("file-renaming") === "true",
|
|
||||||
pathOverriding: localStorage.getItem("path-overriding") === "true",
|
|
||||||
enableCustomArgs: localStorage.getItem("enable-custom-args") === "true",
|
|
||||||
listView: localStorage.getItem("listview") === "true",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const settingsSlice = createSlice({
|
|
||||||
name: "settings",
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
setServerAddr: (state, action: PayloadAction<string>) => {
|
|
||||||
state.serverAddr = action.payload
|
|
||||||
localStorage.setItem("server-addr", action.payload)
|
|
||||||
},
|
|
||||||
setServerPort: (state, action: PayloadAction<string>) => {
|
|
||||||
state.serverPort = action.payload
|
|
||||||
localStorage.setItem("server-port", action.payload)
|
|
||||||
},
|
|
||||||
setLanguage: (state, action: PayloadAction<LanguageUnion>) => {
|
|
||||||
state.language = action.payload
|
|
||||||
localStorage.setItem("language", action.payload)
|
|
||||||
},
|
|
||||||
setCliArgs: (state, action: PayloadAction<string>) => {
|
|
||||||
state.cliArgs = action.payload
|
|
||||||
localStorage.setItem("cli-args", action.payload)
|
|
||||||
},
|
|
||||||
setTheme: (state, action: PayloadAction<ThemeUnion>) => {
|
|
||||||
state.theme = action.payload
|
|
||||||
localStorage.setItem("theme", action.payload)
|
|
||||||
},
|
|
||||||
setFormatSelection: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.formatSelection = action.payload
|
|
||||||
localStorage.setItem("format-selection", action.payload.toString())
|
|
||||||
},
|
|
||||||
setRateLimit: (state, action: PayloadAction<string>) => {
|
|
||||||
state.ratelimit = action.payload
|
|
||||||
localStorage.setItem("rate-limit", action.payload)
|
|
||||||
},
|
|
||||||
setPathOverriding: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.pathOverriding = action.payload
|
|
||||||
localStorage.setItem("path-overriding", action.payload.toString())
|
|
||||||
},
|
|
||||||
setFileRenaming: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.fileRenaming = action.payload
|
|
||||||
localStorage.setItem("file-renaming", action.payload.toString())
|
|
||||||
},
|
|
||||||
setEnableCustomArgs: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.enableCustomArgs = action.payload
|
|
||||||
localStorage.setItem("enable-custom-args", action.payload.toString())
|
|
||||||
},
|
|
||||||
toggleListView: (state) => {
|
|
||||||
state.listView = !state.listView
|
|
||||||
localStorage.setItem("listview", state.listView.toString())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const {
|
|
||||||
setLanguage,
|
|
||||||
setCliArgs,
|
|
||||||
setTheme,
|
|
||||||
setServerAddr,
|
|
||||||
setServerPort,
|
|
||||||
setFormatSelection,
|
|
||||||
setRateLimit,
|
|
||||||
setFileRenaming,
|
|
||||||
setPathOverriding,
|
|
||||||
setEnableCustomArgs,
|
|
||||||
toggleListView
|
|
||||||
} = settingsSlice.actions
|
|
||||||
|
|
||||||
export default settingsSlice.reducer
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
|
||||||
|
|
||||||
export interface StatusState {
|
|
||||||
connected: boolean,
|
|
||||||
updated: boolean,
|
|
||||||
downloading: boolean,
|
|
||||||
freeSpace: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: StatusState = {
|
|
||||||
connected: false,
|
|
||||||
updated: false,
|
|
||||||
downloading: false,
|
|
||||||
freeSpace: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const statusSlice = createSlice({
|
|
||||||
name: 'status',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
connected: (state) => {
|
|
||||||
state.connected = true
|
|
||||||
},
|
|
||||||
disconnected: (state) => {
|
|
||||||
state.connected = false
|
|
||||||
},
|
|
||||||
updated: (state) => {
|
|
||||||
state.updated = true
|
|
||||||
},
|
|
||||||
alreadyUpdated: (state) => {
|
|
||||||
state.updated = false
|
|
||||||
},
|
|
||||||
downloading: (state) => {
|
|
||||||
state.downloading = true
|
|
||||||
},
|
|
||||||
finished: (state) => {
|
|
||||||
state.downloading = false
|
|
||||||
},
|
|
||||||
setFreeSpace: (state, action: PayloadAction<number>) => {
|
|
||||||
state.freeSpace = action.payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const {
|
|
||||||
connected,
|
|
||||||
disconnected,
|
|
||||||
updated,
|
|
||||||
alreadyUpdated,
|
|
||||||
downloading,
|
|
||||||
finished,
|
|
||||||
setFreeSpace
|
|
||||||
} = statusSlice.actions
|
|
||||||
|
|
||||||
export default statusSlice.reducer
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { AlertColor } from '@mui/material'
|
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
|
||||||
|
|
||||||
export interface ToastState {
|
|
||||||
message: string
|
|
||||||
open: boolean
|
|
||||||
autoClose: boolean
|
|
||||||
severity?: AlertColor
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessageAction = {
|
|
||||||
message: string,
|
|
||||||
severity?: AlertColor
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ToastState = {
|
|
||||||
message: '',
|
|
||||||
open: false,
|
|
||||||
autoClose: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toastSlice = createSlice({
|
|
||||||
name: 'toast',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
setMessage: (state, action: PayloadAction<MessageAction>) => {
|
|
||||||
state.message = action.payload.message
|
|
||||||
state.severity = action.payload.severity
|
|
||||||
state.open = true
|
|
||||||
},
|
|
||||||
setOpen: (state) => {
|
|
||||||
state.open = true
|
|
||||||
},
|
|
||||||
setClose: (state) => {
|
|
||||||
state.open = false
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const {
|
|
||||||
setMessage,
|
|
||||||
setClose,
|
|
||||||
setOpen,
|
|
||||||
} = toastSlice.actions
|
|
||||||
|
|
||||||
export default toastSlice.reducer
|
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useDispatch } from "react-redux"
|
import { AlertColor } from '@mui/material'
|
||||||
import { setMessage } from "../features/ui/toastSlice"
|
import { useRecoilState } from 'recoil'
|
||||||
import { AlertColor } from "@mui/material"
|
import { toastListState } from '../atoms/toast'
|
||||||
|
|
||||||
export const useToast = () => {
|
export const useToast = () => {
|
||||||
const dispatch = useDispatch()
|
const [toasts, setToasts] = useRecoilState(toastListState)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pushMessage: (message: string, severity?: AlertColor) => {
|
pushMessage: (message: string, severity?: AlertColor) => {
|
||||||
dispatch(setMessage({
|
setToasts([{
|
||||||
|
open: true,
|
||||||
message: message,
|
message: message,
|
||||||
severity: severity
|
severity: severity,
|
||||||
}))
|
autoClose: true,
|
||||||
|
createdAt: Date.now()
|
||||||
|
}, ...toasts])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
10
frontend/src/hooks/useI18n.ts
Normal file
10
frontend/src/hooks/useI18n.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { i18nBuilderState } from '../atoms/i18n'
|
||||||
|
|
||||||
|
export const useI18n = () => {
|
||||||
|
const instance = useRecoilValue(i18nBuilderState)
|
||||||
|
|
||||||
|
return {
|
||||||
|
i18n: instance
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/hooks/useRPC.ts
Normal file
11
frontend/src/hooks/useRPC.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { rpcClientState } from '../atoms/rpc'
|
||||||
|
|
||||||
|
export const useRPC = () => {
|
||||||
|
const client = useRecoilValue(rpcClientState)
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
socket$: client.socket$
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,27 +2,27 @@
|
|||||||
import i18n from "../assets/i18n.yaml"
|
import i18n from "../assets/i18n.yaml"
|
||||||
|
|
||||||
export default class I18nBuilder {
|
export default class I18nBuilder {
|
||||||
private language: string
|
private language: string
|
||||||
private textMap = i18n.languages
|
private textMap = i18n.languages
|
||||||
|
|
||||||
constructor(language: string) {
|
constructor(language: string) {
|
||||||
this.language = language
|
this.language = language
|
||||||
}
|
}
|
||||||
|
|
||||||
getLanguage(): string {
|
getLanguage(): string {
|
||||||
return this.language
|
return this.language
|
||||||
}
|
}
|
||||||
|
|
||||||
setLanguage(language: string): void {
|
setLanguage(language: string): void {
|
||||||
this.language = language
|
this.language = language
|
||||||
}
|
}
|
||||||
|
|
||||||
t(key: string): string {
|
t(key: string): string {
|
||||||
const map = this.textMap[this.language]
|
const map = this.textMap[this.language]
|
||||||
if (map) {
|
if (map) {
|
||||||
const translation = map[key]
|
const translation = map[key]
|
||||||
return translation ?? 'caption not defined'
|
return translation ?? 'caption not defined'
|
||||||
}
|
|
||||||
return 'caption not defined'
|
|
||||||
}
|
}
|
||||||
}
|
return 'caption not defined'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import type { DLMetadata, RPCRequest, RPCResponse } from '../types'
|
import type { DLMetadata, RPCRequest, RPCResponse } from '../types'
|
||||||
|
|
||||||
import { webSocket } from 'rxjs/webSocket'
|
import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
|
||||||
import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../utils'
|
|
||||||
|
|
||||||
export const socket$ = webSocket<any>(getWebSocketEndpoint())
|
|
||||||
|
|
||||||
export class RPCClient {
|
export class RPCClient {
|
||||||
private seq: number
|
private seq: number
|
||||||
|
private httpEndpoint: string
|
||||||
|
private _socket$: WebSocketSubject<any>
|
||||||
|
|
||||||
constructor() {
|
constructor(httpEndpoint: string, webSocketEndpoint: string) {
|
||||||
this.seq = 0
|
this.seq = 0
|
||||||
|
this.httpEndpoint = httpEndpoint
|
||||||
|
this._socket$ = webSocket<any>(webSocketEndpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
public get socket$() {
|
||||||
|
return this._socket$
|
||||||
}
|
}
|
||||||
|
|
||||||
private incrementSeq() {
|
private incrementSeq() {
|
||||||
@@ -17,14 +22,14 @@ export class RPCClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private send(req: RPCRequest) {
|
private send(req: RPCRequest) {
|
||||||
socket$.next({
|
this._socket$.next({
|
||||||
...req,
|
...req,
|
||||||
id: this.incrementSeq(),
|
id: this.incrementSeq(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendHTTP<T>(req: RPCRequest) {
|
private async sendHTTP<T>(req: RPCRequest) {
|
||||||
const res = await fetch(getHttpRPCEndpoint(), {
|
const res = await fetch(this.httpEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...req,
|
...req,
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
import { Alert, Snackbar } from "@mui/material"
|
import { Alert, Snackbar } from "@mui/material"
|
||||||
import { useDispatch, useSelector } from "react-redux"
|
import { useRecoilState } from 'recoil'
|
||||||
import { setClose } from "../features/ui/toastSlice"
|
import { toastListState } from '../atoms/toast'
|
||||||
import { RootState } from "../stores/store"
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
const Toaster: React.FC = () => {
|
const Toaster: React.FC = () => {
|
||||||
const toast = useSelector((state: RootState) => state.toast)
|
const [toasts, setToasts] = useRecoilState(toastListState)
|
||||||
const dispatch = useDispatch()
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (toasts.length > 0) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setToasts(t => t.filter((x) => (Date.now() - x.createdAt) < 1500))
|
||||||
|
}, 1500)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [setToasts, toasts])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Snackbar
|
<>
|
||||||
open={toast.open}
|
{toasts.map((toast, index) => (
|
||||||
autoHideDuration={toast.severity === 'error' ? 10000 : 1500}
|
<Snackbar
|
||||||
onClose={() => dispatch(setClose())}
|
key={index}
|
||||||
>
|
open={toast.open}
|
||||||
<Alert variant="filled" severity={toast.severity}>
|
>
|
||||||
{toast.message}
|
<Alert variant="filled" severity={toast.severity}>
|
||||||
</Alert>
|
{toast.message}
|
||||||
</Snackbar>
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { createContext, useMemo } from 'react'
|
|
||||||
import { useSelector } from 'react-redux'
|
|
||||||
import I18nBuilder from '../lib/intl'
|
|
||||||
import { RootState, store } from '../stores/store'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Context {
|
|
||||||
i18n: I18nBuilder
|
|
||||||
}
|
|
||||||
|
|
||||||
export const I18nContext = createContext<Context>({
|
|
||||||
i18n: new I18nBuilder(store.getState().settings.language)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function I18nProvider({ children }: Props) {
|
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
|
||||||
|
|
||||||
const i18n = useMemo(() => new I18nBuilder(
|
|
||||||
settings.language
|
|
||||||
), [settings.language])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<I18nContext.Provider value={{ i18n }}>
|
|
||||||
{children}
|
|
||||||
</I18nContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { createContext, useMemo } from 'react'
|
|
||||||
import { useSelector } from 'react-redux'
|
|
||||||
import { RPCClient } from '../lib/rpcClient'
|
|
||||||
import type { RootState } from '../stores/store'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Context {
|
|
||||||
client: RPCClient
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RPCClientContext = createContext<Context>({
|
|
||||||
client: new RPCClient()
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function RPCClientProvider({ children }: Props) {
|
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
|
||||||
|
|
||||||
const client = useMemo(() => new RPCClient(), [
|
|
||||||
settings.serverAddr,
|
|
||||||
settings.serverPort,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RPCClientContext.Provider value={{ client }}>
|
|
||||||
{children}
|
|
||||||
</RPCClientContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit'
|
|
||||||
import settingsReducer from '../features/settings/settingsSlice'
|
|
||||||
import statussReducer from '../features/status/statusSlice'
|
|
||||||
import toastReducer from '../features/ui/toastSlice'
|
|
||||||
|
|
||||||
export const store = configureStore({
|
|
||||||
reducer: {
|
|
||||||
settings: settingsReducer,
|
|
||||||
status: statussReducer,
|
|
||||||
toast: toastReducer,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>
|
|
||||||
|
|
||||||
export type AppDispatch = typeof store.dispatch
|
|
||||||
|
|
||||||
@@ -27,28 +27,25 @@ import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
|||||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
||||||
|
|
||||||
import { Buffer } from 'buffer'
|
import { Buffer } from 'buffer'
|
||||||
import { useContext, useEffect, useMemo, useState, useTransition } from 'react'
|
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
import { useObservable } from '../hooks/observable'
|
import { useObservable } from '../hooks/observable'
|
||||||
import { RootState } from '../stores/store'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
import { DeleteRequest, DirectoryEntry } from '../types'
|
import { DeleteRequest, DirectoryEntry } from '../types'
|
||||||
import { roundMiB } from '../utils'
|
import { roundMiB } from '../utils'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { ffetch } from '../lib/httpClient'
|
|
||||||
import { I18nContext } from '../providers/i18nProvider'
|
|
||||||
|
|
||||||
export default function Downloaded() {
|
export default function Downloaded() {
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const serverAddr = useRecoilValue(serverURL)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { i18n } = useContext(I18nContext)
|
const { i18n } = useI18n()
|
||||||
|
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
const serverAddr =
|
|
||||||
`${window.location.protocol}//${settings.serverAddr}:${settings.serverPort}`
|
|
||||||
|
|
||||||
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
||||||
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
||||||
|
|
||||||
@@ -138,8 +135,7 @@ export default function Downloaded() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetcher()
|
fetcher()
|
||||||
}, [settings.serverAddr, settings.serverPort])
|
}, [serverAddr])
|
||||||
|
|
||||||
|
|
||||||
const onFileClick = (path: string) => startTransition(() => {
|
const onFileClick = (path: string) => startTransition(() => {
|
||||||
window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`)
|
window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`)
|
||||||
|
|||||||
@@ -1,168 +1,77 @@
|
|||||||
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
|
||||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
|
||||||
import {
|
import {
|
||||||
Backdrop,
|
Backdrop,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container
|
||||||
SpeedDial,
|
|
||||||
SpeedDialAction,
|
|
||||||
SpeedDialIcon
|
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useContext, useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
|
import { serverAddressAndPortState } from '../atoms/settings'
|
||||||
|
import { connectedState, freeSpaceBytesState, isDownloadingState } from '../atoms/status'
|
||||||
import DownloadDialog from '../components/DownloadDialog'
|
import DownloadDialog from '../components/DownloadDialog'
|
||||||
import { DownloadsCardView } from '../components/DownloadsCardView'
|
import Downloads from '../components/Downloads'
|
||||||
import { DownloadsListView } from '../components/DownloadsListView'
|
import HomeSpeedDial from '../components/HomeSpeedDial'
|
||||||
import Splash from '../components/Splash'
|
import Splash from '../components/Splash'
|
||||||
import { toggleListView } from '../features/settings/settingsSlice'
|
|
||||||
import { connected, setFreeSpace } from '../features/status/statusSlice'
|
|
||||||
import { useToast } from '../hooks/toast'
|
import { useToast } from '../hooks/toast'
|
||||||
import { socket$ } from '../lib/rpcClient'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { I18nContext } from '../providers/i18nProvider'
|
import { useRPC } from '../hooks/useRPC'
|
||||||
import { RPCClientContext } from '../providers/rpcClientProvider'
|
|
||||||
import { RootState } from '../stores/store'
|
|
||||||
import type { RPCResponse, RPCResult } from '../types'
|
|
||||||
import { datetimeCompareFunc, isRPCResponse } from '../utils'
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const isDownloading = useRecoilValue(isDownloadingState)
|
||||||
const status = useSelector((state: RootState) => state.status)
|
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
|
||||||
const dispatch = useDispatch()
|
|
||||||
|
const [, setFreeSpace] = useRecoilState(freeSpaceBytesState)
|
||||||
|
const [isConnected, setIsDownloading] = useRecoilState(connectedState)
|
||||||
|
|
||||||
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
|
|
||||||
const [showBackdrop, setShowBackdrop] = useState(true)
|
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
const { i18n } = useContext(I18nContext)
|
const { i18n } = useI18n()
|
||||||
const { client } = useContext(RPCClientContext)
|
const { client } = useRPC()
|
||||||
|
|
||||||
const { pushMessage } = useToast()
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
/* -------------------- Effects -------------------- */
|
|
||||||
|
|
||||||
/* WebSocket connect event handler*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status.connected) { return }
|
if (isConnected) {
|
||||||
|
|
||||||
const sub = socket$.subscribe({
|
|
||||||
next: () => {
|
|
||||||
dispatch(connected())
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
pushMessage(
|
|
||||||
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
|
|
||||||
"error"
|
|
||||||
)
|
|
||||||
setShowBackdrop(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => sub.unsubscribe()
|
|
||||||
}, [socket$, status.connected])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status.connected) {
|
|
||||||
client.running()
|
client.running()
|
||||||
const interval = setInterval(() => client.running(), 1000)
|
const interval = setInterval(() => client.running(), 1000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}
|
}
|
||||||
}, [status.connected])
|
}, [isConnected])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client
|
client
|
||||||
.freeSpace()
|
.freeSpace()
|
||||||
.then(bytes => dispatch(setFreeSpace(bytes.result)))
|
.then(bytes => setFreeSpace(bytes.result))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
pushMessage(
|
pushMessage(
|
||||||
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
|
`${i18n.t('rpcConnErr')} (${serverAddressAndPort})`,
|
||||||
"error"
|
"error"
|
||||||
)
|
)
|
||||||
setShowBackdrop(false)
|
setIsDownloading(false)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!status.connected) { return }
|
|
||||||
|
|
||||||
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
|
|
||||||
if (!isRPCResponse(event)) { return }
|
|
||||||
|
|
||||||
setActiveDownloads((event.result ?? [])
|
|
||||||
.filter(f => !!f.info.url)
|
|
||||||
.sort((a, b) => datetimeCompareFunc(
|
|
||||||
b.info.created_at,
|
|
||||||
a.info.created_at,
|
|
||||||
)))
|
|
||||||
})
|
|
||||||
|
|
||||||
pushMessage(
|
|
||||||
`Connected to (${settings.serverAddr}:${settings.serverPort})`,
|
|
||||||
"success"
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => sub.unsubscribe()
|
|
||||||
}, [socket$, status.connected])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeDownloads && activeDownloads.length >= 0) {
|
|
||||||
setShowBackdrop(false)
|
|
||||||
}
|
|
||||||
}, [activeDownloads?.length])
|
|
||||||
|
|
||||||
const abort = (id?: string) => {
|
|
||||||
if (id) {
|
|
||||||
client.kill(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client.killAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
<Backdrop
|
<Backdrop
|
||||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||||
open={showBackdrop}
|
open={!isDownloading}
|
||||||
>
|
>
|
||||||
<CircularProgress color="primary" />
|
<CircularProgress color="primary" />
|
||||||
</Backdrop>
|
</Backdrop>
|
||||||
{activeDownloads?.length === 0 &&
|
<Splash />
|
||||||
<Splash />
|
<Downloads />
|
||||||
}
|
<HomeSpeedDial
|
||||||
{
|
onOpen={() => setOpenDialog(true)}
|
||||||
settings.listView ?
|
/>
|
||||||
<DownloadsListView downloads={activeDownloads ?? []} onStop={abort} /> :
|
|
||||||
<DownloadsCardView downloads={activeDownloads ?? []} onStop={abort} />
|
|
||||||
}
|
|
||||||
<SpeedDial
|
|
||||||
ariaLabel="SpeedDial basic example"
|
|
||||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
|
||||||
icon={<SpeedDialIcon />}
|
|
||||||
>
|
|
||||||
<SpeedDialAction
|
|
||||||
icon={<FormatListBulleted />}
|
|
||||||
tooltipTitle={`Table view`}
|
|
||||||
onClick={() => dispatch(toggleListView())}
|
|
||||||
/>
|
|
||||||
<SpeedDialAction
|
|
||||||
icon={<DeleteForeverIcon />}
|
|
||||||
tooltipTitle={i18n.t('abortAllButton')}
|
|
||||||
onClick={() => abort()}
|
|
||||||
/>
|
|
||||||
<SpeedDialAction
|
|
||||||
icon={<AddCircleIcon />}
|
|
||||||
tooltipTitle={`New download`}
|
|
||||||
onClick={() => setOpenDialog(true)}
|
|
||||||
/>
|
|
||||||
</SpeedDial>
|
|
||||||
<DownloadDialog
|
<DownloadDialog
|
||||||
open={openDialog}
|
open={openDialog}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setOpenDialog(false)
|
setOpenDialog(false)
|
||||||
setShowBackdrop(false)
|
setIsDownloading(false)
|
||||||
}}
|
}}
|
||||||
onDownloadStart={() => {
|
onDownloadStart={() => {
|
||||||
setOpenDialog(false)
|
setOpenDialog(false)
|
||||||
setShowBackdrop(true)
|
setIsDownloading(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { getHttpEndpoint } from '../utils'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { getHttpEndpoint } from '../utils'
|
||||||
|
|
||||||
const LoginContainer = styled(Container)({
|
const LoginContainer = styled(Container)({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography,
|
||||||
|
capitalize
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useContext, useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useRecoilState } from 'recoil'
|
||||||
import {
|
import {
|
||||||
Subject,
|
Subject,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
@@ -26,38 +27,45 @@ import {
|
|||||||
takeWhile
|
takeWhile
|
||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
import {
|
import {
|
||||||
LanguageUnion,
|
Language,
|
||||||
ThemeUnion,
|
Theme,
|
||||||
setCliArgs,
|
enableCustomArgsState,
|
||||||
setEnableCustomArgs,
|
fileRenamingState,
|
||||||
setFileRenaming,
|
formatSelectionState,
|
||||||
setFormatSelection,
|
languageState,
|
||||||
setLanguage,
|
languages,
|
||||||
setPathOverriding,
|
latestCliArgumentsState,
|
||||||
setServerAddr,
|
pathOverridingState,
|
||||||
setServerPort,
|
serverAddressState,
|
||||||
setTheme
|
serverPortState,
|
||||||
} from '../features/settings/settingsSlice'
|
themeState
|
||||||
|
} from '../atoms/settings'
|
||||||
import { useToast } from '../hooks/toast'
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { useRPC } from '../hooks/useRPC'
|
||||||
import { CliArguments } from '../lib/argsParser'
|
import { CliArguments } from '../lib/argsParser'
|
||||||
import { I18nContext } from '../providers/i18nProvider'
|
|
||||||
import { RPCClientContext } from '../providers/rpcClientProvider'
|
|
||||||
import { RootState } from '../stores/store'
|
|
||||||
import { validateDomain, validateIP } from '../utils'
|
import { validateDomain, validateIP } from '../utils'
|
||||||
|
|
||||||
|
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const dispatch = useDispatch()
|
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
|
||||||
|
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
|
||||||
|
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
|
||||||
|
const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState)
|
||||||
|
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState)
|
||||||
|
const [serverPort, setServerPort] = useRecoilState(serverPortState)
|
||||||
|
const [language, setLanguage] = useRecoilState(languageState)
|
||||||
|
const [cliArgs, setCliArgs] = useRecoilState(latestCliArgumentsState)
|
||||||
|
const [theme, setTheme] = useRecoilState(themeState)
|
||||||
|
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const [invalidIP, setInvalidIP] = useState(false)
|
||||||
|
|
||||||
const [invalidIP, setInvalidIP] = useState(false);
|
const { i18n } = useI18n()
|
||||||
|
const { client } = useRPC()
|
||||||
const { i18n } = useContext(I18nContext)
|
|
||||||
const { client } = useContext(RPCClientContext)
|
|
||||||
|
|
||||||
const { pushMessage } = useToast()
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
|
const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), [])
|
||||||
|
|
||||||
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
||||||
const serverPort$ = useMemo(() => new Subject<string>(), [])
|
const serverPort$ = useMemo(() => new Subject<string>(), [])
|
||||||
@@ -71,10 +79,10 @@ export default function Settings() {
|
|||||||
.subscribe(addr => {
|
.subscribe(addr => {
|
||||||
if (validateIP(addr)) {
|
if (validateIP(addr)) {
|
||||||
setInvalidIP(false)
|
setInvalidIP(false)
|
||||||
dispatch(setServerAddr(addr))
|
setServerAddr(addr)
|
||||||
} else if (validateDomain(addr)) {
|
} else if (validateDomain(addr)) {
|
||||||
setInvalidIP(false)
|
setInvalidIP(false)
|
||||||
dispatch(setServerAddr(addr))
|
setServerAddr(addr)
|
||||||
} else {
|
} else {
|
||||||
setInvalidIP(true)
|
setInvalidIP(true)
|
||||||
}
|
}
|
||||||
@@ -90,7 +98,7 @@ export default function Settings() {
|
|||||||
takeWhile(val => isFinite(val) && val <= 65535),
|
takeWhile(val => isFinite(val) && val <= 65535),
|
||||||
)
|
)
|
||||||
.subscribe(port => {
|
.subscribe(port => {
|
||||||
dispatch(setServerPort(port.toString()))
|
setServerPort(port)
|
||||||
})
|
})
|
||||||
return () => sub.unsubscribe()
|
return () => sub.unsubscribe()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -98,15 +106,15 @@ export default function Settings() {
|
|||||||
/**
|
/**
|
||||||
* Language toggler handler
|
* Language toggler handler
|
||||||
*/
|
*/
|
||||||
const handleLanguageChange = (event: SelectChangeEvent<LanguageUnion>) => {
|
const handleLanguageChange = (event: SelectChangeEvent<Language>) => {
|
||||||
dispatch(setLanguage(event.target.value as LanguageUnion));
|
setLanguage(event.target.value as Language)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Theme toggler handler
|
* Theme toggler handler
|
||||||
*/
|
*/
|
||||||
const handleThemeChange = (event: SelectChangeEvent<ThemeUnion>) => {
|
const handleThemeChange = (event: SelectChangeEvent<Theme>) => {
|
||||||
dispatch(setTheme(event.target.value as ThemeUnion));
|
setTheme(event.target.value as Theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,7 +145,7 @@ export default function Settings() {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label={i18n.t('serverAddressTitle')}
|
label={i18n.t('serverAddressTitle')}
|
||||||
defaultValue={settings.serverAddr}
|
defaultValue={serverAddr}
|
||||||
error={invalidIP}
|
error={invalidIP}
|
||||||
onChange={(e) => serverAddr$.next(e.currentTarget.value)}
|
onChange={(e) => serverAddr$.next(e.currentTarget.value)}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -150,9 +158,9 @@ export default function Settings() {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label={i18n.t('serverPortTitle')}
|
label={i18n.t('serverPortTitle')}
|
||||||
defaultValue={settings.serverPort}
|
defaultValue={serverPort}
|
||||||
onChange={(e) => serverPort$.next(e.currentTarget.value)}
|
onChange={(e) => serverPort$.next(e.currentTarget.value)}
|
||||||
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
|
error={isNaN(Number(serverPort)) || Number(serverPort) > 65535}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -162,20 +170,15 @@ export default function Settings() {
|
|||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
|
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={settings.language}
|
defaultValue={language}
|
||||||
label={i18n.t('languageSelect')}
|
label={i18n.t('languageSelect')}
|
||||||
onChange={handleLanguageChange}
|
onChange={handleLanguageChange}
|
||||||
>
|
>
|
||||||
<MenuItem value="english">English</MenuItem>
|
{languages.map(l => (
|
||||||
<MenuItem value="spanish">Spanish</MenuItem>
|
<MenuItem value={l} key={l}>
|
||||||
<MenuItem value="italian">Italian</MenuItem>
|
{capitalize(l)}
|
||||||
<MenuItem value="chinese">Chinese</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem value="russian">Russian</MenuItem>
|
))}
|
||||||
<MenuItem value="korean">Korean</MenuItem>
|
|
||||||
<MenuItem value="japanese">Japanese</MenuItem>
|
|
||||||
<MenuItem value="catalan">Catalan</MenuItem>
|
|
||||||
<MenuItem value="ukrainian">Ukrainian</MenuItem>
|
|
||||||
<MenuItem value="polish">Polish</MenuItem>
|
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -183,7 +186,7 @@ export default function Settings() {
|
|||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>{i18n.t('themeSelect')}</InputLabel>
|
<InputLabel>{i18n.t('themeSelect')}</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={settings.theme}
|
defaultValue={theme}
|
||||||
label={i18n.t('themeSelect')}
|
label={i18n.t('themeSelect')}
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
>
|
>
|
||||||
@@ -196,8 +199,8 @@ export default function Settings() {
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={cliArgs.noMTime}
|
defaultChecked={argsBuilder.noMTime}
|
||||||
onChange={() => dispatch(setCliArgs(cliArgs.toggleNoMTime().toString()))}
|
onChange={() => setCliArgs(argsBuilder.toggleNoMTime().toString())}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={i18n.t('noMTimeCheckbox')}
|
label={i18n.t('noMTimeCheckbox')}
|
||||||
@@ -206,9 +209,9 @@ export default function Settings() {
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={cliArgs.extractAudio}
|
defaultChecked={argsBuilder.extractAudio}
|
||||||
onChange={() => dispatch(setCliArgs(cliArgs.toggleExtractAudio().toString()))}
|
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
|
||||||
disabled={settings.formatSelection}
|
disabled={formatSelection}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={i18n.t('extractAudioCheckbox')}
|
label={i18n.t('extractAudioCheckbox')}
|
||||||
@@ -216,10 +219,10 @@ export default function Settings() {
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={settings.formatSelection}
|
defaultChecked={formatSelection}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
dispatch(setCliArgs(cliArgs.disableExtractAudio().toString()))
|
setCliArgs(argsBuilder.disableExtractAudio().toString())
|
||||||
dispatch(setFormatSelection(!settings.formatSelection))
|
setFormatSelection(!formatSelection)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -233,9 +236,9 @@ export default function Settings() {
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={settings.pathOverriding}
|
defaultChecked={!!pathOverriding}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
dispatch(setPathOverriding(!settings.pathOverriding))
|
setPathOverriding(state => !state)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -244,9 +247,9 @@ export default function Settings() {
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={settings.fileRenaming}
|
defaultChecked={fileRenaming}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
dispatch(setFileRenaming(!settings.fileRenaming))
|
setFileRenaming(state => !state)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -255,9 +258,9 @@ export default function Settings() {
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={settings.enableCustomArgs}
|
defaultChecked={enableArgs}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
dispatch(setEnableCustomArgs(!settings.enableCustomArgs))
|
setEnableArgs(state => !state)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -281,5 +284,5 @@ export default function Settings() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user