diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx index cbd3245..65c0aa9 100644 --- a/frontend/src/Layout.tsx +++ b/frontend/src/Layout.tsx @@ -5,7 +5,6 @@ import DownloadIcon from '@mui/icons-material/Download' import Menu from '@mui/icons-material/Menu' import SettingsIcon from '@mui/icons-material/Settings' import SettingsEthernet from '@mui/icons-material/SettingsEthernet' -import Storage from '@mui/icons-material/Storage' import { Box, createTheme } from '@mui/material' import CssBaseline from '@mui/material/CssBaseline' import Divider from '@mui/material/Divider' @@ -21,20 +20,20 @@ import { useMemo, useState } from 'react' import { Link, Outlet } from 'react-router-dom' import { useRecoilValue } from 'recoil' import { settingsState } from './atoms/settings' -import { statusState } from './atoms/status' +import { connectedState } from './atoms/status' import AppBar from './components/AppBar' import Drawer from './components/Drawer' +import FreeSpaceIndicator from './components/FreeSpaceIndicator' import Logout from './components/Logout' +import SocketSubscriber from './components/SocketSubscriber' import ThemeToggler from './components/ThemeToggler' import Toaster from './providers/ToasterProvider' -import { formatGiB } from './utils' -import SocketSubscriber from './components/SocketSubscriber' export default function Layout() { const [open, setOpen] = useState(false) const settings = useRecoilValue(settingsState) - const status = useRecoilValue(statusState) + const isConnected = useRecoilValue(connectedState) const mode = settings.theme const theme = useMemo(() => @@ -48,9 +47,7 @@ export default function Layout() { }), [settings.theme] ) - const toggleDrawer = () => { - setOpen(state => !state) - } + const toggleDrawer = () => setOpen(state => !state) return ( @@ -80,20 +77,7 @@ export default function Layout() { > yt-dlp WebUI - { - status.freeSpace ? -
- - -  {formatGiB(status.freeSpace)}  - -
- : null - } +
-  {status.connected ? settings.serverAddr : 'not connected'} +  {isConnected ? settings.serverAddr : 'not connected'}
diff --git a/frontend/src/atoms/status.ts b/frontend/src/atoms/status.ts index 99abde8..33d676c 100644 --- a/frontend/src/atoms/status.ts +++ b/frontend/src/atoms/status.ts @@ -1,10 +1,10 @@ import { atom, selector } from 'recoil' +import { rpcClientState } from './rpc' type StatusState = { connected: boolean, updated: boolean, downloading: boolean, - freeSpace: number, } @@ -23,17 +23,18 @@ export const isDownloadingState = atom({ default: false }) -export const freeSpaceBytesState = atom({ - key: 'freeSpaceBytesState', - default: 0 -}) +// export const freeSpaceBytesState = selector({ +// key: 'freeSpaceBytesState', +// get: async ({ get }) => { +// const res = await get(rpcClientState).freeSpace() +// return res.result +// } +// }) -export const statusState = selector({ - key: 'statusState', - get: ({ get }) => ({ - connected: get(connectedState), - updated: get(updatedBinaryState), - downloading: get(isDownloadingState), - freeSpace: get(freeSpaceBytesState), - }) +export const availableDownloadPathsState = selector({ + key: 'availableDownloadPathsState', + get: async ({ get }) => { + const res = await get(rpcClientState).directoryTree() + return res.result + } }) \ No newline at end of file diff --git a/frontend/src/atoms/ui.ts b/frontend/src/atoms/ui.ts new file mode 100644 index 0000000..7d302fa --- /dev/null +++ b/frontend/src/atoms/ui.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil' + +export const loadingAtom = atom({ + key: 'loadingAtom', + default: false +}) \ No newline at end of file diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 9f3a29a..54b42be 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -26,15 +26,14 @@ import { TransitionProps } from '@mui/material/transitions' import { Buffer } from 'buffer' import { forwardRef, - useEffect, useMemo, useRef, useState, useTransition } from 'react' -import { useRecoilState, useRecoilValue } from 'recoil' +import { useRecoilValue } from 'recoil' import { settingsState } from '../atoms/settings' -import { connectedState } from '../atoms/status' +import { availableDownloadPathsState, connectedState } from '../atoms/status' import FormatsGrid from '../components/FormatsGrid' import { useI18n } from '../hooks/useI18n' import { useRPC } from '../hooks/useRPC' @@ -64,7 +63,8 @@ export default function DownloadDialog({ }: Props) { // recoil state const settings = useRecoilValue(settingsState) - const [isConnected] = useRecoilState(connectedState) + const isConnected = useRecoilValue(connectedState) + const availableDownloadPaths = useRecoilValue(availableDownloadPathsState) // ephemeral state const [downloadFormats, setDownloadFormats] = useState() @@ -74,7 +74,6 @@ export default function DownloadDialog({ const [customArgs, setCustomArgs] = useState('') const [downloadPath, setDownloadPath] = useState(0) - const [availableDownloadPaths, setAvailableDownloadPaths] = useState([]) const [fileNameOverride, setFilenameOverride] = useState('') @@ -96,19 +95,6 @@ export default function DownloadDialog({ const urlInputRef = useRef(null) const customFilenameInputRef = useRef(null) - // effects - useEffect(() => { - client.directoryTree() - .then(data => { - setAvailableDownloadPaths(data.result) - }) - }, []) - - useEffect(() => { - setCustomArgs(localStorage.getItem('last-input-args') ?? '') - setFilenameOverride(localStorage.getItem('last-filename-override') ?? '') - }, []) - // transitions const [isPending, startTransition] = useTransition() diff --git a/frontend/src/components/Downloads.tsx b/frontend/src/components/Downloads.tsx index 17761be..5382e31 100644 --- a/frontend/src/components/Downloads.tsx +++ b/frontend/src/components/Downloads.tsx @@ -1,71 +1,31 @@ +import { useEffect } from 'react' 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' +import { loadingAtom } from '../atoms/ui' +import DownloadsCardView from './DownloadsCardView' +import DownloadsListView from './DownloadsListView' const Downloads: React.FC = () => { - const [active, setActive] = useRecoilState(activeDownloadsState) - const isConnected = useRecoilValue(connectedState) const listView = useRecoilValue(listViewState) + const active = useRecoilValue(activeDownloadsState) - 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) => { - 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) + const [, setIsLoading] = useRecoilState(loadingAtom) useEffect(() => { if (active) { - setIsDownloading(true) + setIsLoading(true) } }, [active?.length]) if (listView) { return ( - + ) } return ( - + ) } diff --git a/frontend/src/components/DownloadsCardView.tsx b/frontend/src/components/DownloadsCardView.tsx index 6e1a4c8..3befbc2 100644 --- a/frontend/src/components/DownloadsCardView.tsx +++ b/frontend/src/components/DownloadsCardView.tsx @@ -1,19 +1,21 @@ import { Grid } from "@mui/material" import { Fragment } from "react" +import { useRecoilValue } from 'recoil' +import { activeDownloadsState } from '../atoms/downloads' import { useToast } from "../hooks/toast" import { useI18n } from '../hooks/useI18n' -import type { RPCResult } from "../types" +import { useRPC } from '../hooks/useRPC' import { StackableResult } from "./StackableResult" -type Props = { - downloads: RPCResult[] - onStop: (id: string) => void -} +const DownloadsCardView: React.FC = () => { + const downloads = useRecoilValue(activeDownloadsState) ?? [] -export function DownloadsCardView({ downloads, onStop }: Props) { const { i18n } = useI18n() + const { client } = useRPC() const { pushMessage } = useToast() + const abort = (id: string) => client.kill(id) + return ( { @@ -25,7 +27,7 @@ export function DownloadsCardView({ downloads, onStop }: Props) { title={download.info.title} thumbnail={download.info.thumbnail} percentage={download.progress.percentage} - onStop={() => onStop(download.id)} + onStop={() => abort(download.id)} onCopy={() => pushMessage(i18n.t('clipboardAction'))} resolution={download.info.resolution ?? ''} speed={download.progress.speed} @@ -38,4 +40,6 @@ export function DownloadsCardView({ downloads, onStop }: Props) { } ) -} \ No newline at end of file +} + +export default DownloadsCardView \ No newline at end of file diff --git a/frontend/src/components/DownloadsListView.tsx b/frontend/src/components/DownloadsListView.tsx index 9994992..2324134 100644 --- a/frontend/src/components/DownloadsListView.tsx +++ b/frontend/src/components/DownloadsListView.tsx @@ -11,15 +11,19 @@ import { TableRow, Typography } from "@mui/material" +import { useRecoilValue } from 'recoil' +import { activeDownloadsState } from '../atoms/downloads' +import { useRPC } from '../hooks/useRPC' import { ellipsis, formatSpeedMiB, roundMiB } from "../utils" -import type { RPCResult } from "../types" -type Props = { - downloads: RPCResult[] - onStop: (id: string) => void -} -export const DownloadsListView: React.FC = ({ downloads, onStop }) => { +const DownloadsListView: React.FC = () => { + const downloads = useRecoilValue(activeDownloadsState) ?? [] + + const { client } = useRPC() + + const abort = (id: string) => client.kill(id) + return ( @@ -70,7 +74,7 @@ export const DownloadsListView: React.FC = ({ downloads, onStop }) => { @@ -84,4 +88,6 @@ export const DownloadsListView: React.FC = ({ downloads, onStop }) => { ) -} \ No newline at end of file +} + +export default DownloadsListView \ No newline at end of file diff --git a/frontend/src/components/FreeSpaceIndicator.tsx b/frontend/src/components/FreeSpaceIndicator.tsx new file mode 100644 index 0000000..d9bcb34 --- /dev/null +++ b/frontend/src/components/FreeSpaceIndicator.tsx @@ -0,0 +1,29 @@ +import StorageIcon from '@mui/icons-material/Storage' +import { useEffect, useState } from 'react' +import { formatGiB } from '../utils' +import { useRPC } from '../hooks/useRPC' + +const FreeSpaceIndicator = () => { + const [freeSpace, setFreeSpace] = useState(0) + + const { client } = useRPC() + + useEffect(() => { + client.freeSpace().then(r => setFreeSpace(r.result)) + }, [client]) + + return ( +
+ + +  {formatGiB(freeSpace)}  + +
+ ) +} + +export default FreeSpaceIndicator \ No newline at end of file diff --git a/frontend/src/components/HomeActions.tsx b/frontend/src/components/HomeActions.tsx new file mode 100644 index 0000000..1cae5de --- /dev/null +++ b/frontend/src/components/HomeActions.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react' +import { useRecoilState } from 'recoil' +import { loadingAtom } from '../atoms/ui' +import DownloadDialog from './DownloadDialog' +import HomeSpeedDial from './HomeSpeedDial' + +const HomeActions: React.FC = () => { + const [, setIsLoading] = useRecoilState(loadingAtom) + const [openDialog, setOpenDialog] = useState(false) + + return ( + <> + setOpenDialog(true)} + /> + { + setOpenDialog(false) + setIsLoading(true) + }} + onDownloadStart={() => { + setOpenDialog(false) + setIsLoading(true) + }} + /> + + ) +} + +export default HomeActions \ No newline at end of file diff --git a/frontend/src/components/LoadingBackdrop.tsx b/frontend/src/components/LoadingBackdrop.tsx new file mode 100644 index 0000000..09d3e2b --- /dev/null +++ b/frontend/src/components/LoadingBackdrop.tsx @@ -0,0 +1,18 @@ +import { Backdrop, CircularProgress } from '@mui/material' +import { useRecoilValue } from 'recoil' +import { loadingAtom } from '../atoms/ui' + +const LoadingBackdrop: React.FC = () => { + const isLoading = useRecoilValue(loadingAtom) + + return ( + theme.zIndex.drawer + 1 }} + open={!isLoading} + > + + + ) +} + +export default LoadingBackdrop \ No newline at end of file diff --git a/frontend/src/components/Logout.tsx b/frontend/src/components/Logout.tsx index 4953a07..463a950 100644 --- a/frontend/src/components/Logout.tsx +++ b/frontend/src/components/Logout.tsx @@ -1,13 +1,15 @@ import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material' import LogoutIcon from '@mui/icons-material/Logout' -import { getHttpEndpoint } from '../utils' import { useNavigate } from 'react-router-dom' +import { useRecoilValue } from 'recoil' +import { serverURL } from '../atoms/settings' export default function Logout() { const navigate = useNavigate() + const url = useRecoilValue(serverURL) const logout = async () => { - const res = await fetch(`${getHttpEndpoint()}/auth/logout`) + const res = await fetch(`${url}/auth/logout`) if (res.ok) { navigate('/login') } diff --git a/frontend/src/components/SocketSubscriber.tsx b/frontend/src/components/SocketSubscriber.tsx index 01d3999..3252aab 100644 --- a/frontend/src/components/SocketSubscriber.tsx +++ b/frontend/src/components/SocketSubscriber.tsx @@ -1,42 +1,62 @@ -import { useEffect } from 'react' +import { useMemo } from 'react' import { useRecoilState, useRecoilValue } from 'recoil' -import { connectedState } from '../atoms/status' -import { useRPC } from '../hooks/useRPC' -import { useToast } from '../hooks/toast' +import { interval, share, take } from 'rxjs' +import { activeDownloadsState } from '../atoms/downloads' import { serverAddressAndPortState } from '../atoms/settings' +import { connectedState } from '../atoms/status' +import { useSubscription } from '../hooks/observable' +import { useToast } from '../hooks/toast' import { useI18n } from '../hooks/useI18n' +import { useRPC } from '../hooks/useRPC' +import { datetimeCompareFunc, isRPCResponse } from '../utils' interface Props extends React.HTMLAttributes { } const SocketSubscriber: React.FC = ({ children }) => { - const [isConnected, setIsConnected] = useRecoilState(connectedState) + const [, setIsConnected] = useRecoilState(connectedState) + const [, setActive] = useRecoilState(activeDownloadsState) + const serverAddressAndPort = useRecoilValue(serverAddressAndPortState) const { i18n } = useI18n() - const { socket$ } = useRPC() + const { client } = useRPC() const { pushMessage } = useToast() - useEffect(() => { - if (isConnected) { return } + const sharedSocket$ = useMemo(() => client.socket$.pipe(share()), []) + const socketOnce$ = useMemo(() => sharedSocket$.pipe(take(1)), []) - 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]) + useSubscription(socketOnce$, () => { + setIsConnected(true) + pushMessage( + `${i18n.t('toastConnected')} (${serverAddressAndPort})`, + "success" + ) + }) + + useSubscription(sharedSocket$, + (event) => { + 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, + )) + ) + }, + (err) => { + console.error(err) + pushMessage( + `${i18n.t('rpcConnErr')} (${serverAddressAndPort})`, + "error" + ) + } + ) + + useSubscription(interval(1000), () => client.running()) return ( <>{children} diff --git a/frontend/src/components/StackableResult.tsx b/frontend/src/components/StackableResult.tsx index 4b08ed6..90a2128 100644 --- a/frontend/src/components/StackableResult.tsx +++ b/frontend/src/components/StackableResult.tsx @@ -12,7 +12,6 @@ import { Stack, Typography } from '@mui/material' -import { useEffect, useState } from 'react' import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils' type Props = { @@ -40,15 +39,11 @@ export function StackableResult({ onStop, onCopy, }: Props) { - const [isCompleted, setIsCompleted] = useState(false) + const isCompleted = () => percentage === '-1' - useEffect(() => { - if (percentage === '-1') { - setIsCompleted(true) - } - }, [percentage]) - - const percentageToNumber = () => isCompleted ? 100 : Number(percentage.replace('%', '')) + const percentageToNumber = () => isCompleted() + ? 100 + : Number(percentage.replace('%', '')) const guessResolution = (xByY: string): any => { if (!xByY) return null @@ -82,12 +77,12 @@ export function StackableResult({ } - {!isCompleted ? percentage : ''} - {!isCompleted ? formatSpeedMiB(speed) : ''} + {!isCompleted() ? percentage : ''} + {!isCompleted() ? formatSpeedMiB(speed) : ''} {roundMiB(size ?? 0)} {guessResolution(resolution)} @@ -95,7 +90,7 @@ export function StackableResult({ : null } @@ -108,7 +103,7 @@ export function StackableResult({ color="primary" onClick={onStop} > - {isCompleted ? "Clear" : "Stop"} + {isCompleted() ? "Clear" : "Stop"} diff --git a/frontend/src/hooks/useRPC.ts b/frontend/src/hooks/useRPC.ts index 5de6414..fbb9422 100644 --- a/frontend/src/hooks/useRPC.ts +++ b/frontend/src/hooks/useRPC.ts @@ -5,7 +5,6 @@ export const useRPC = () => { const client = useRecoilValue(rpcClientState) return { - client, - socket$: client.socket$ + client } } \ No newline at end of file diff --git a/frontend/src/lib/rpcClient.ts b/frontend/src/lib/rpcClient.ts index 8dfd1a0..af2abe6 100644 --- a/frontend/src/lib/rpcClient.ts +++ b/frontend/src/lib/rpcClient.ts @@ -1,11 +1,12 @@ -import type { DLMetadata, RPCRequest, RPCResponse } from '../types' +import { Observable, share } from 'rxjs' +import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types' import { WebSocketSubject, webSocket } from 'rxjs/webSocket' export class RPCClient { private seq: number private httpEndpoint: string - private _socket$: WebSocketSubject + private readonly _socket$: WebSocketSubject constructor(httpEndpoint: string, webSocketEndpoint: string) { this.seq = 0 @@ -13,8 +14,8 @@ export class RPCClient { this._socket$ = webSocket(webSocketEndpoint) } - public get socket$() { - return this._socket$ + public get socket$(): Observable> { + return this._socket$.asObservable() } private incrementSeq() { @@ -52,7 +53,7 @@ export class RPCClient { return } if (playlist) { - return this.send({ + return this.sendHTTP({ method: 'Service.ExecPlaylist', params: [{ URL: url, @@ -61,7 +62,7 @@ export class RPCClient { }] }) } - this.send({ + this.sendHTTP({ method: 'Service.Exec', params: [{ URL: url.split("?list").at(0)!, @@ -91,14 +92,14 @@ export class RPCClient { } public kill(id: string) { - this.send({ + this.sendHTTP({ method: 'Service.Kill', params: [id], }) } public killAll() { - this.send({ + this.sendHTTP({ method: 'Service.KillAll', params: [], }) diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 79c8f5f..c798946 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -75,19 +75,6 @@ export function toFormatArgs(codes: string[]): string { return '' } -export function getWebSocketEndpoint() { - const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' - return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/ws` -} - -export function getHttpRPCEndpoint() { - return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/http` -} - -export function getHttpEndpoint() { - return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}` -} - export function formatGiB(bytes: number) { return `${(bytes / 1_000_000_000).toFixed(0)}GiB` } diff --git a/frontend/src/views/Home.tsx b/frontend/src/views/Home.tsx index baef968..2d01bf1 100644 --- a/frontend/src/views/Home.tsx +++ b/frontend/src/views/Home.tsx @@ -1,79 +1,18 @@ import { - Backdrop, - CircularProgress, Container } from '@mui/material' -import { useEffect, useState } from 'react' -import { useRecoilState, useRecoilValue } from 'recoil' -import { serverAddressAndPortState } from '../atoms/settings' -import { connectedState, freeSpaceBytesState, isDownloadingState } from '../atoms/status' -import DownloadDialog from '../components/DownloadDialog' import Downloads from '../components/Downloads' -import HomeSpeedDial from '../components/HomeSpeedDial' +import HomeActions from '../components/HomeActions' +import LoadingBackdrop from '../components/LoadingBackdrop' import Splash from '../components/Splash' -import { useToast } from '../hooks/toast' -import { useI18n } from '../hooks/useI18n' -import { useRPC } from '../hooks/useRPC' export default function Home() { - const isDownloading = useRecoilValue(isDownloadingState) - const serverAddressAndPort = useRecoilValue(serverAddressAndPortState) - - const [, setFreeSpace] = useRecoilState(freeSpaceBytesState) - const [isConnected, setIsDownloading] = useRecoilState(connectedState) - - const [openDialog, setOpenDialog] = useState(false) - - const { i18n } = useI18n() - const { client } = useRPC() - - const { pushMessage } = useToast() - - useEffect(() => { - if (isConnected) { - client.running() - const interval = setInterval(() => client.running(), 1000) - return () => clearInterval(interval) - } - }, [isConnected]) - - useEffect(() => { - client - .freeSpace() - .then(bytes => setFreeSpace(bytes.result)) - .catch(() => { - pushMessage( - `${i18n.t('rpcConnErr')} (${serverAddressAndPort})`, - "error" - ) - setIsDownloading(false) - }) - }, []) - return ( - theme.zIndex.drawer + 1 }} - open={!isDownloading} - > - - + - setOpenDialog(true)} - /> - { - setOpenDialog(false) - setIsDownloading(false) - }} - onDownloadStart={() => { - setOpenDialog(false) - setIsDownloading(false) - }} - /> + ) } diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx index f068f92..e81e7de 100644 --- a/frontend/src/views/Login.tsx +++ b/frontend/src/views/Login.tsx @@ -13,7 +13,8 @@ import { } from '@mui/material' import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { getHttpEndpoint } from '../utils' +import { useRecoilValue } from 'recoil' +import { serverURL } from '../atoms/settings' const LoginContainer = styled(Container)({ display: 'flex', @@ -35,10 +36,12 @@ export default function Login() { const [secret, setSecret] = useState('') const [formHasError, setFormHasError] = useState(false) + const url = useRecoilValue(serverURL) + const navigate = useNavigate() const login = async () => { - const res = await fetch(`${getHttpEndpoint()}/auth/login`, { + const res = await fetch(`${url}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json'