support for cron based subscriptions management
This commit is contained in:
@@ -6,6 +6,7 @@ import LiveTvIcon from '@mui/icons-material/LiveTv'
|
||||
import Menu from '@mui/icons-material/Menu'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import TerminalIcon from '@mui/icons-material/Terminal'
|
||||
import UpdateIcon from '@mui/icons-material/Update'
|
||||
import { Box, createTheme } from '@mui/material'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import Divider from '@mui/material/Divider'
|
||||
@@ -140,6 +141,19 @@ export default function Layout() {
|
||||
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/subscriptions'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton>
|
||||
<ListItemIcon>
|
||||
<UpdateIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={i18n.t('subscriptionsButtonLabel')} />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/monitor'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
|
||||
@@ -70,4 +70,13 @@ keys:
|
||||
deleteCookies: Delete Cookies
|
||||
noFilesFound: 'No Files Found'
|
||||
tableView: 'Table View'
|
||||
deleteSelected: 'Delete selected'
|
||||
deleteSelected: 'Delete selected'
|
||||
subscriptionsButtonLabel: 'Subscriptions'
|
||||
subscriptionsEmptyLabel: 'No subscriptions'
|
||||
subscriptionsURLInput: 'Channel URL'
|
||||
subscriptionsInfo: |
|
||||
Subscribes to a defined channel. Only the last video will be downloaded.
|
||||
The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank).
|
||||
cronExpressionLabel: 'Cron expression'
|
||||
editButtonLabel: 'Edit'
|
||||
newSubscriptionButton: New subscription
|
||||
@@ -1,7 +1,5 @@
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import {
|
||||
Alert,
|
||||
AppBar,
|
||||
@@ -20,13 +18,13 @@ import {
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { forwardRef, useEffect, useState, useTransition } from 'react'
|
||||
import { serverURL } from '../atoms/settings'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { CustomTemplate } from '../types'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import TemplateTextField from './TemplateTextField'
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
|
||||
38
frontend/src/components/subscriptions/NoSubscriptions.tsx
Normal file
38
frontend/src/components/subscriptions/NoSubscriptions.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import UpdateIcon from '@mui/icons-material/Update'
|
||||
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
||||
import { useI18n } from '../../hooks/useI18n'
|
||||
|
||||
const FlexContainer = styled(Container)({
|
||||
display: 'flex',
|
||||
minWidth: '100%',
|
||||
minHeight: '80vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
})
|
||||
|
||||
const Title = styled(Typography)({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: '0.5rem'
|
||||
})
|
||||
|
||||
|
||||
export default function NoSubscriptions() {
|
||||
const { i18n } = useI18n()
|
||||
|
||||
return (
|
||||
<FlexContainer>
|
||||
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
|
||||
<SvgIcon sx={{ fontSize: '200px' }}>
|
||||
<UpdateIcon />
|
||||
</SvgIcon>
|
||||
</Title>
|
||||
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||
{i18n.t('subscriptionsEmptyLabel')}
|
||||
</Title>
|
||||
</FlexContainer>
|
||||
)
|
||||
}
|
||||
164
frontend/src/components/subscriptions/SubscriptionsDialog.tsx
Normal file
164
frontend/src/components/subscriptions/SubscriptionsDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import {
|
||||
Alert,
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Dialog,
|
||||
Grid,
|
||||
IconButton,
|
||||
Paper,
|
||||
Slide,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { forwardRef, startTransition, useState } from 'react'
|
||||
import { customArgsState } from '../../atoms/downloadTemplate'
|
||||
import { serverURL } from '../../atoms/settings'
|
||||
import { useToast } from '../../hooks/toast'
|
||||
import { useI18n } from '../../hooks/useI18n'
|
||||
import { ffetch } from '../../lib/httpClient'
|
||||
import { Subscription } from '../../services/subscriptions'
|
||||
import ExtraDownloadOptions from '../ExtraDownloadOptions'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
props: TransitionProps & {
|
||||
children: React.ReactElement
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
return <Slide direction="up" ref={ref} {...props} />
|
||||
})
|
||||
|
||||
const SubscriptionsDialog: React.FC<Props> = ({ open, onClose }) => {
|
||||
const [subscriptionURL, setSubscriptionURL] = useState('')
|
||||
const [subscriptionCron, setSubscriptionCron] = useState('')
|
||||
|
||||
const customArgs = useAtomValue(customArgsState)
|
||||
|
||||
const { i18n } = useI18n()
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const baseURL = useAtomValue(serverURL)
|
||||
|
||||
const submit = async (sub: Omit<Subscription, 'id'>) => {
|
||||
const task = ffetch<void>(`${baseURL}/subscriptions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(sub)
|
||||
})
|
||||
const either = await task()
|
||||
|
||||
pipe(
|
||||
either,
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => onClose()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
TransitionComponent={Transition}
|
||||
>
|
||||
<AppBar sx={{ position: 'relative' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={onClose}
|
||||
aria-label="close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||
{i18n.t('subscriptionsButtonLabel')}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box sx={{
|
||||
backgroundColor: (theme) => theme.palette.background.default,
|
||||
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||
}}>
|
||||
<Container sx={{ my: 4 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Grid container gap={1.5}>
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
{i18n.t('subscriptionsInfo')}
|
||||
</Alert>
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
{i18n.t('livestreamExperimentalWarning')}
|
||||
</Alert>
|
||||
</Grid>
|
||||
<Grid item xs={12} mt={1}>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
label={i18n.t('subscriptionsURLInput')}
|
||||
variant="outlined"
|
||||
placeholder="https://www.youtube.com/@SomeChannelThatExists/videos"
|
||||
onChange={(e) => setSubscriptionURL(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={8} mt={-2}>
|
||||
<ExtraDownloadOptions />
|
||||
</Grid>
|
||||
<Grid item xs={3.871}>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
label={i18n.t('cronExpressionLabel')}
|
||||
variant="outlined"
|
||||
placeholder="*/5 * * * *"
|
||||
onChange={(e) => setSubscriptionCron(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
variant="contained"
|
||||
disabled={subscriptionURL === ''}
|
||||
onClick={() => startTransition(() => submit({
|
||||
url: subscriptionURL,
|
||||
params: customArgs,
|
||||
cron_expression: subscriptionCron
|
||||
}))}
|
||||
>
|
||||
{i18n.t('startButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionsDialog
|
||||
@@ -0,0 +1,162 @@
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import {
|
||||
Alert,
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Dialog,
|
||||
Grid,
|
||||
IconButton,
|
||||
Paper,
|
||||
Slide,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { forwardRef, startTransition, useState } from 'react'
|
||||
import { customArgsState } from '../../atoms/downloadTemplate'
|
||||
import { serverURL } from '../../atoms/settings'
|
||||
import { useToast } from '../../hooks/toast'
|
||||
import { useI18n } from '../../hooks/useI18n'
|
||||
import { ffetch } from '../../lib/httpClient'
|
||||
import { Subscription } from '../../services/subscriptions'
|
||||
import ExtraDownloadOptions from '../ExtraDownloadOptions'
|
||||
|
||||
type Props = {
|
||||
subscription: Subscription | undefined
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
props: TransitionProps & {
|
||||
children: React.ReactElement
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
return <Slide direction="up" ref={ref} {...props} />
|
||||
})
|
||||
|
||||
const SubscriptionsEditDialog: React.FC<Props> = ({ subscription, onClose }) => {
|
||||
const [subscriptionURL, setSubscriptionURL] = useState('')
|
||||
const [subscriptionCron, setSubscriptionCron] = useState('')
|
||||
|
||||
const customArgs = useAtomValue(customArgsState)
|
||||
|
||||
const { i18n } = useI18n()
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const baseURL = useAtomValue(serverURL)
|
||||
|
||||
const editSubscription = async (sub: Subscription) => {
|
||||
const task = ffetch<void>(`${baseURL}/subscriptions`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(sub)
|
||||
})
|
||||
const either = await task()
|
||||
|
||||
pipe(
|
||||
either,
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => onClose()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={!!subscription}
|
||||
TransitionComponent={Transition}
|
||||
>
|
||||
<AppBar sx={{ position: 'relative' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => onClose()}
|
||||
aria-label="close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||
{i18n.t('subscriptionsButtonLabel')}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box sx={{
|
||||
backgroundColor: (theme) => theme.palette.background.default,
|
||||
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
|
||||
}}>
|
||||
<Container sx={{ my: 4 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Grid container gap={1.5}>
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
Editing {subscription?.url}
|
||||
</Alert>
|
||||
</Grid>
|
||||
<Grid item xs={12} mt={1}>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
label={i18n.t('subscriptionsURLInput')}
|
||||
variant="outlined"
|
||||
defaultValue={subscription?.url}
|
||||
placeholder="https://www.youtube.com/@SomeChannelThatExists/videos"
|
||||
onChange={(e) => setSubscriptionURL(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={8} mt={-2}>
|
||||
<ExtraDownloadOptions />
|
||||
</Grid>
|
||||
<Grid item xs={3.871}>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
label={i18n.t('cronExpressionLabel')}
|
||||
variant="outlined"
|
||||
placeholder="*/5 * * * *"
|
||||
defaultValue={subscription?.cron_expression}
|
||||
onChange={(e) => setSubscriptionCron(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
variant="contained"
|
||||
onClick={() => startTransition(async () => await editSubscription({
|
||||
id: subscription?.id ?? '',
|
||||
url: subscriptionURL || subscription?.url!,
|
||||
params: customArgs || subscription?.params!,
|
||||
cron_expression: subscriptionCron || subscription?.cron_expression!
|
||||
}))}
|
||||
>
|
||||
{i18n.t('editButtonLabel')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionsEditDialog
|
||||
@@ -0,0 +1,27 @@
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||
import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material'
|
||||
import { useI18n } from '../../hooks/useI18n'
|
||||
|
||||
type Props = {
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
const SubscriptionsSpeedDial: React.FC<Props> = ({ onOpen }) => {
|
||||
const { i18n } = useI18n()
|
||||
|
||||
return (
|
||||
<SpeedDial
|
||||
ariaLabel="Subscriptions speed dial"
|
||||
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
||||
icon={<SpeedDialIcon />}
|
||||
>
|
||||
<SpeedDialAction
|
||||
icon={<AddCircleIcon />}
|
||||
tooltipTitle={i18n.t('newSubscriptionButton')}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
</SpeedDial>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionsSpeedDial
|
||||
35
frontend/src/hooks/useFetch.ts
Normal file
35
frontend/src/hooks/useFetch.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { serverURL } from '../atoms/settings'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { useToast } from './toast'
|
||||
|
||||
const useFetch = <R>(resource: string) => {
|
||||
const base = useAtomValue(serverURL)
|
||||
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const [data, setData] = useState<R>()
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
const fetcher = () => pipe(
|
||||
ffetch<R>(`${base}${resource}`),
|
||||
matchW(
|
||||
(l) => {
|
||||
setError(l)
|
||||
pushMessage(l, 'error')
|
||||
},
|
||||
(r) => setData(r)
|
||||
)
|
||||
)()
|
||||
|
||||
useEffect(() => {
|
||||
fetcher()
|
||||
}, [])
|
||||
|
||||
return { data, error, fetcher }
|
||||
}
|
||||
|
||||
export default useFetch
|
||||
@@ -1,12 +1,6 @@
|
||||
import { tryCatch } from 'fp-ts/TaskEither'
|
||||
|
||||
export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
|
||||
() => fetcher<T>(url, opt),
|
||||
(e) => `error while fetching: ${e}`
|
||||
)
|
||||
|
||||
|
||||
const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
||||
async function fetcher<T>(url: string, opt?: RequestInit): Promise<T> {
|
||||
const jwt = localStorage.getItem('token')
|
||||
|
||||
if (opt && !opt.headers) {
|
||||
@@ -26,5 +20,11 @@ const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
||||
if (!res.ok) {
|
||||
throw await res.text()
|
||||
}
|
||||
|
||||
return res.json() as T
|
||||
}
|
||||
}
|
||||
|
||||
export const ffetch = <T>(url: string, opt?: RequestInit) => tryCatch(
|
||||
() => fetcher<T>(url, opt),
|
||||
(e) => `error while fetching: ${e}`
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ export default class Translator {
|
||||
t(key: string): string {
|
||||
if (this.current) {
|
||||
//@ts-ignore
|
||||
return this.current[key] ?? fallback.keys[key]
|
||||
return this.current[key] ?? fallback.keys[key] ?? 'caption not defined'
|
||||
}
|
||||
return 'caption not defined'
|
||||
}
|
||||
|
||||
@@ -42,10 +42,12 @@ export class RPCClient {
|
||||
}
|
||||
|
||||
private argsSanitizer(args: string): string[] {
|
||||
const splitOnlyWhitespaces = /[^\s"']+|"([^"]*)"|'([^']*)'/gm
|
||||
|
||||
return args
|
||||
.split(' ')
|
||||
.map(a => a.trim().replaceAll('"', ''))
|
||||
.filter(Boolean)
|
||||
.match(splitOnlyWhitespaces)
|
||||
?.map(a => a.trim())
|
||||
.filter(Boolean) ?? []
|
||||
}
|
||||
|
||||
private async sendHTTP<T>(req: RPCRequest) {
|
||||
|
||||
@@ -10,6 +10,7 @@ const Archive = lazy(() => import('./views/Archive'))
|
||||
const Settings = lazy(() => import('./views/Settings'))
|
||||
const LiveStream = lazy(() => import('./views/Livestream'))
|
||||
const Filebrowser = lazy(() => import('./views/Filebrowser'))
|
||||
const Subscriptions = lazy(() => import('./views/Subscriptions'))
|
||||
|
||||
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
||||
|
||||
@@ -73,6 +74,19 @@ export const router = createHashRouter([
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/subscriptions',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Subscriptions />
|
||||
</Suspense >
|
||||
),
|
||||
errorElement: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<ErrorBoundary />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: (
|
||||
|
||||
37
frontend/src/services/subscriptions.ts
Normal file
37
frontend/src/services/subscriptions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// import { PaginatedResponse } from '../types'
|
||||
|
||||
export type Subscription = {
|
||||
id: string
|
||||
url: string
|
||||
params: string
|
||||
cron_expression: string
|
||||
}
|
||||
|
||||
// class SubscriptionService {
|
||||
// private _baseURL: string = ''
|
||||
|
||||
// public set baseURL(v: string) {
|
||||
// this._baseURL = v
|
||||
// }
|
||||
|
||||
// public async delete(id: string): Promise<void> {
|
||||
|
||||
// }
|
||||
|
||||
// public async listPaginated(start: number, limit: number = 50): Promise<PaginatedResponse<Subscription[]>> {
|
||||
// const res = await fetch(`${this._baseURL}/subscriptions?id=${start}&limit=${limit}`)
|
||||
// const data: PaginatedResponse<Subscription[]> = await res.json()
|
||||
|
||||
// return data
|
||||
// }
|
||||
|
||||
// public async submit(sub: Subscription): Promise<void> {
|
||||
|
||||
// }
|
||||
|
||||
// public async edit(sub: Subscription): Promise<void> {
|
||||
|
||||
// }
|
||||
// }
|
||||
|
||||
// export default SubscriptionService
|
||||
157
frontend/src/views/Subscriptions.tsx
Normal file
157
frontend/src/views/Subscriptions.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
Table, TableBody, TableCell, TableContainer,
|
||||
TableHead, TablePagination, TableRow
|
||||
} from '@mui/material'
|
||||
import { matchW } from 'fp-ts/lib/Either'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { serverURL } from '../atoms/settings'
|
||||
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||
import NoSubscriptions from '../components/subscriptions/NoSubscriptions'
|
||||
import SubscriptionsDialog from '../components/subscriptions/SubscriptionsDialog'
|
||||
import SubscriptionsEditDialog from '../components/subscriptions/SubscriptionsEditDialog'
|
||||
import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import useFetch from '../hooks/useFetch'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { Subscription } from '../services/subscriptions'
|
||||
import { PaginatedResponse } from '../types'
|
||||
|
||||
const SubscriptionsView: React.FC = () => {
|
||||
const { i18n } = useI18n()
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const baseURL = useAtomValue(serverURL)
|
||||
|
||||
const [selectedSubscription, setSelectedSubscription] = useState<Subscription>()
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
|
||||
const [startId, setStartId] = useState(0)
|
||||
const [limit, setLimit] = useState(9)
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const { data: subs, fetcher: refecth } = useFetch<PaginatedResponse<Subscription[]>>(
|
||||
`/subscriptions?id=${startId}&limit=${limit}`
|
||||
)
|
||||
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const deleteSubscription = async (id: string) => {
|
||||
const task = ffetch<void>(`${baseURL}/subscriptions/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const either = await task()
|
||||
|
||||
pipe(
|
||||
either,
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
() => refecth()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingBackdrop isLoading={!subs || isPending} />
|
||||
|
||||
<SubscriptionsSpeedDial onOpen={() => setOpenDialog(s => !s)} />
|
||||
|
||||
<SubscriptionsEditDialog
|
||||
subscription={selectedSubscription}
|
||||
onClose={() => {
|
||||
setSelectedSubscription(undefined)
|
||||
refecth()
|
||||
}}
|
||||
/>
|
||||
<SubscriptionsDialog open={openDialog} onClose={() => {
|
||||
setOpenDialog(s => !s)
|
||||
refecth()
|
||||
}} />
|
||||
|
||||
{!subs || subs.data.length === 0 ?
|
||||
<NoSubscriptions /> :
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
|
||||
<Paper sx={{
|
||||
p: 2.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '80vh',
|
||||
}}>
|
||||
<TableContainer component={Box}>
|
||||
<Table sx={{ minWidth: '100%' }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="left">URL</TableCell>
|
||||
<TableCell align="right">Params</TableCell>
|
||||
<TableCell align="right">{i18n.t('cronExpressionLabel')}</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody sx={{ mb: 'auto' }}>
|
||||
{subs.data.map(x => (
|
||||
<TableRow
|
||||
key={x.id}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell>{x.url}</TableCell>
|
||||
<TableCell align='right'>
|
||||
{x.params}
|
||||
</TableCell>
|
||||
<TableCell align='right'>
|
||||
{x.cron_expression}
|
||||
</TableCell>
|
||||
<TableCell align='center'>
|
||||
<Button
|
||||
variant='contained'
|
||||
size='small'
|
||||
sx={{ mr: 0.5 }}
|
||||
onClick={() => setSelectedSubscription(x)}
|
||||
>
|
||||
<EditIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant='contained'
|
||||
size='small'
|
||||
onClick={() => startTransition(async () => await deleteSubscription(x.id))}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={-1}
|
||||
page={page}
|
||||
onPageChange={(_, p) => {
|
||||
if (p < page) {
|
||||
setPage(s => (s - 1 <= 0 ? 0 : s - 1))
|
||||
setStartId(subs.first)
|
||||
return
|
||||
}
|
||||
setPage(s => s + 1)
|
||||
setStartId(subs.next)
|
||||
}}
|
||||
rowsPerPage={limit}
|
||||
rowsPerPageOptions={[9, 10, 25, 50, 100]}
|
||||
onRowsPerPageChange={(e) => { setLimit(parseInt(e.target.value)) }}
|
||||
/>
|
||||
</Paper>
|
||||
</Container>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionsView
|
||||
Reference in New Issue
Block a user