diff --git a/.gitignore b/.gitignore index 167234e..2492410 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ frontend/.yarn/install-state.gz livestreams.dat .vite/deps archive.txt +web_config.yml diff --git a/frontend/package.json b/frontend/package.json index 77c17b3..a67b2a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,11 +18,12 @@ "@mui/icons-material": "^6.2.0", "@mui/material": "^6.2.0", "fp-ts": "^2.16.5", + "jotai": "^2.10.3", + "jotai-cache": "^0.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^6.23.1", "react-virtuoso": "^4.7.11", - "jotai": "^2.10.3", "rxjs": "^7.8.1" }, "devDependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f53bf48..a87e7fe 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: jotai: specifier: ^2.10.3 version: 2.10.3(@types/react@19.0.1)(react@19.0.0) + jotai-cache: + specifier: ^0.5.0 + version: 0.5.0(jotai@2.10.3(@types/react@19.0.1)(react@19.0.0)) react: specifier: ^19.0.0 version: 19.0.0 @@ -737,6 +740,11 @@ packages: is-core-module@2.12.1: resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} + jotai-cache@0.5.0: + resolution: {integrity: sha512-29pUuEfSXL7Ba6lxZmiNDARc73TspWzAzCy0jCkk2uEOnFJ6kaUBZTp/AZSwnIsh1ndfUfM9/QpbLU7uJAQL0A==} + peerDependencies: + jotai: '>=2.0.0' + jotai@2.10.3: resolution: {integrity: sha512-Nnf4IwrLhNfuz2JOQLI0V/AgwcpxvVy8Ec8PidIIDeRi4KCFpwTFIpHAAcU+yCgnw/oASYElq9UY0YdUUegsSA==} engines: {node: '>=12.20.0'} @@ -1512,6 +1520,10 @@ snapshots: dependencies: has: 1.0.3 + jotai-cache@0.5.0(jotai@2.10.3(@types/react@19.0.1)(react@19.0.0)): + dependencies: + jotai: 2.10.3(@types/react@19.0.1)(react@19.0.0) + jotai@2.10.3(@types/react@19.0.1)(react@19.0.0): optionalDependencies: '@types/react': 19.0.1 diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx index 0d136bc..491e11e 100644 --- a/frontend/src/Layout.tsx +++ b/frontend/src/Layout.tsx @@ -16,13 +16,13 @@ import ListItemButton from '@mui/material/ListItemButton' import ListItemIcon from '@mui/material/ListItemIcon' import ListItemText from '@mui/material/ListItemText' import Toolbar from '@mui/material/Toolbar' -import Typography from '@mui/material/Typography' import { grey } from '@mui/material/colors' import { useAtomValue } from 'jotai' import { useMemo, useState } from 'react' import { Link, Outlet } from 'react-router-dom' import { settingsState } from './atoms/settings' import AppBar from './components/AppBar' +import { AppTitle } from './components/AppTitle' import Drawer from './components/Drawer' import Footer from './components/Footer' import Logout from './components/Logout' @@ -76,15 +76,7 @@ export default function Layout() { > - - {settings.appTitle} - + diff --git a/frontend/src/atoms/downloadTemplate.ts b/frontend/src/atoms/downloadTemplate.ts index babb921..c10985e 100644 --- a/frontend/src/atoms/downloadTemplate.ts +++ b/frontend/src/atoms/downloadTemplate.ts @@ -1,12 +1,12 @@ import { getOrElse } from 'fp-ts/lib/Either' import { pipe } from 'fp-ts/lib/function' -import { atom } from 'jotai' +import { atomWithCache } from 'jotai-cache' import { atomWithStorage } from 'jotai/utils' import { ffetch } from '../lib/httpClient' import { CustomTemplate } from '../types' import { serverSideCookiesState, serverURL } from './settings' -export const cookiesTemplateState = atom>(async (get) => +export const cookiesTemplateState = atomWithCache>(async (get) => await get(serverSideCookiesState) ? '--cookies=cookies.txt' : '' @@ -22,7 +22,7 @@ export const filenameTemplateState = atomWithStorage( localStorage.getItem('lastFilenameTemplate') ?? '' ) -export const savedTemplatesState = atom>(async (get) => { +export const savedTemplatesState = atomWithCache>(async (get) => { const task = ffetch(`${get(serverURL)}/api/v1/template/all`) const either = await task() @@ -30,5 +30,4 @@ export const savedTemplatesState = atom>(async (get) = either, getOrElse(() => new Array()) ) -} -) \ No newline at end of file +}) \ No newline at end of file diff --git a/frontend/src/atoms/settings.ts b/frontend/src/atoms/settings.ts index 986531f..6e370c8 100644 --- a/frontend/src/atoms/settings.ts +++ b/frontend/src/atoms/settings.ts @@ -1,9 +1,9 @@ import { pipe } from 'fp-ts/lib/function' import { matchW } from 'fp-ts/lib/TaskEither' +import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' import { ffetch } from '../lib/httpClient' import { prefersDarkMode } from '../utils' -import { atomWithStorage } from 'jotai/utils' -import { atom } from 'jotai' export const languages = [ 'catalan', @@ -121,7 +121,8 @@ export const appTitleState = atomWithStorage( export const serverAddressAndPortState = atom((get) => { if (get(servedFromReverseProxySubDirState)) { return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/` - .replaceAll('"', '') // TODO: atomWithStorage put extra double quotes on strings + .replaceAll('"', '') // XXX: atomWithStorage uses JSON.stringify to serialize + .replaceAll('//', '/') // which puts extra double quotes. } if (get(servedFromReverseProxyState)) { return `${get(serverAddressState)}` diff --git a/frontend/src/components/AppTitle.tsx b/frontend/src/components/AppTitle.tsx new file mode 100644 index 0000000..52fbd03 --- /dev/null +++ b/frontend/src/components/AppTitle.tsx @@ -0,0 +1,33 @@ +import { Typography } from '@mui/material' +import { useAtom } from 'jotai' +import { useEffect } from 'react' +import { appTitleState } from '../atoms/settings' +import useFetch from '../hooks/useFetch' + +export const AppTitle: React.FC = () => { + const [appTitle, setAppTitle] = useAtom(appTitleState) + + const { data } = useFetch<{ title: string }>('/webconfig') + + useEffect(() => { + if (data?.title) { + setAppTitle( + data.title.startsWith('"') + ? data.title.substring(1, data.title.length - 1) + : data.title + ) + } + }, [data]) + + return ( + + {appTitle.startsWith('"') ? appTitle.substring(1, appTitle.length - 1) : appTitle} + + ) +} \ No newline at end of file diff --git a/frontend/src/components/subscriptions/SubscriptionsDialog.tsx b/frontend/src/components/subscriptions/SubscriptionsDialog.tsx index 88ee34f..c0bd32e 100644 --- a/frontend/src/components/subscriptions/SubscriptionsDialog.tsx +++ b/frontend/src/components/subscriptions/SubscriptionsDialog.tsx @@ -15,7 +15,7 @@ import { Typography } from '@mui/material' import { TransitionProps } from '@mui/material/transitions' -import { matchW } from 'fp-ts/lib/Either' +import { matchW } from 'fp-ts/lib/TaskEither' import { pipe } from 'fp-ts/lib/function' import { useAtomValue } from 'jotai' import { forwardRef, startTransition, useState } from 'react' @@ -52,21 +52,16 @@ const SubscriptionsDialog: React.FC = ({ open, onClose }) => { const baseURL = useAtomValue(serverURL) - const submit = async (sub: Omit) => { - const task = ffetch(`${baseURL}/subscriptions`, { + const submit = async (sub: Omit) => pipe( + ffetch(`${baseURL}/subscriptions`, { method: 'POST', body: JSON.stringify(sub) - }) - const either = await task() - - pipe( - either, - matchW( - (l) => pushMessage(l, 'error'), - (_) => onClose() - ) + }), + matchW( + (l) => pushMessage(l, 'error'), + (_) => onClose() ) - } + )() return ( (resource: string) => { const base = useAtomValue(serverURL) @@ -26,7 +32,10 @@ const useFetch = (resource: string) => { )() useEffect(() => { + const controller = new AbortController() fetcher() + + return () => controller.abort() }, []) return { data, error, fetcher } diff --git a/frontend/src/lib/httpClient.ts b/frontend/src/lib/httpClient.ts index 9ddb98a..dea59d2 100644 --- a/frontend/src/lib/httpClient.ts +++ b/frontend/src/lib/httpClient.ts @@ -1,6 +1,9 @@ import { tryCatch } from 'fp-ts/TaskEither' +import * as J from 'fp-ts/Json' +import * as E from 'fp-ts/Either' +import { pipe } from 'fp-ts/lib/function' -async function fetcher(url: string, opt?: RequestInit): Promise { +async function fetcher(url: string, opt?: RequestInit, controller?: AbortController): Promise { const jwt = localStorage.getItem('token') if (opt && !opt.headers) { @@ -14,17 +17,25 @@ async function fetcher(url: string, opt?: RequestInit): Promise { headers: { ...opt?.headers, 'X-Authentication': jwt ?? '' - } + }, + signal: controller?.signal }) if (!res.ok) { throw await res.text() } - return res.json() as T + return res.text() } -export const ffetch = (url: string, opt?: RequestInit) => tryCatch( - () => fetcher(url, opt), +export const ffetch = (url: string, opt?: RequestInit, controller?: AbortController) => tryCatch( + async () => pipe( + await fetcher(url, opt, controller), + J.parse, + E.match( + (l) => l as T, + (r) => r as T + ) + ), (e) => `error while fetching: ${e}` ) diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index 34ac0cb..60bcba7 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -17,7 +17,9 @@ import { Typography, capitalize } from '@mui/material' -import { useAtom } from 'jotai' +import { pipe } from 'fp-ts/lib/function' +import { matchW } from 'fp-ts/lib/TaskEither' +import { useAtom, useAtomValue } from 'jotai' import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { Subject, @@ -34,9 +36,9 @@ import { accentState, accents, appTitleState, + autoFileExtensionState, enableCustomArgsState, fileRenamingState, - autoFileExtensionState, formatSelectionState, languageState, languages, @@ -45,12 +47,14 @@ import { servedFromReverseProxySubDirState, serverAddressState, serverPortState, + serverURL, themeState } from '../atoms/settings' import CookiesTextField from '../components/CookiesTextField' import UpdateBinaryButton from '../components/UpdateBinaryButton' import { useToast } from '../hooks/toast' import { useI18n } from '../hooks/useI18n' +import { ffetch } from '../lib/httpClient' import Translator from '../lib/i18n' import { validateDomain, validateIP } from '../utils' @@ -70,7 +74,7 @@ export default function Settings() { const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState) const [language, setLanguage] = useAtom(languageState) - const [appTitle, setApptitle] = useAtom(appTitleState) + const [appTitle, setAppTitle] = useAtom(appTitleState) const [accent, setAccent] = useAtom(accentState) const [theme, setTheme] = useAtom(themeState) @@ -81,7 +85,11 @@ export default function Settings() { const { pushMessage } = useToast() + // TODO: change name + const derivedServerURL = useAtomValue(serverURL) + const baseURL$ = useMemo(() => new Subject(), []) + const appTitle$ = useMemo(() => new Subject(), []) const serverAddr$ = useMemo(() => new Subject(), []) const serverPort$ = useMemo(() => new Subject(), []) @@ -134,6 +142,25 @@ export default function Settings() { return () => sub.unsubscribe() }, []) + // TODO: refactor out of component. maybe use withAtomEffect from jotai/effect package. + useEffect(() => { + const sub = appTitle$ + .pipe(debounceTime(500)) + .subscribe(title => { + pipe( + ffetch(`${derivedServerURL}/webconfig/title`, { + method: 'PATCH', + body: JSON.stringify(title) + }), + matchW( + (l) => pushMessage(l, 'error'), + (_) => setAppTitle(title) + ) + )() + }) + return () => sub.unsubscribe() + }, []) + /** * Language toggler handler */ @@ -194,7 +221,7 @@ export default function Settings() { fullWidth label={i18n.t('appTitle')} defaultValue={appTitle} - onChange={(e) => setApptitle(e.currentTarget.value)} + onChange={(e) => appTitle$.next(e.target.value)} error={appTitle === ''} /> @@ -218,7 +245,7 @@ export default function Settings() { { value: 500, label: '500 ms' }, { value: 750, label: '750 ms' }, { value: 1000, label: '1000 ms' }, - { value: 2000, label: '2000 ms' }, + { value: 2000, label: '2 s' }, ]} onChange={(_, value) => typeof value === 'number' ? setPollingTime(value) @@ -367,7 +394,7 @@ export default function Settings() { /> } label={i18n.t('autoFileExtensionOption')} - /> + /> } = 250 && ac.RPCPollingTime <= 2000 { + c.Config.RPCPollingTime = ac.RPCPollingTime + } +} + +func getConfigurationFile() (*os.File, error) { + fd, err := os.OpenFile( + filepath.Join(config.Instance().Dir(), "web_config.yml"), + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644, + ) + if err != nil { + return nil, err + } + + return fd, nil +} diff --git a/server/configurator/handlers.go b/server/configurator/handlers.go new file mode 100644 index 0000000..4649110 --- /dev/null +++ b/server/configurator/handlers.go @@ -0,0 +1,103 @@ +package configurator + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// App configurator REST handlers + +func GetConfig(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(Instance().Config); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func SetConfig(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + w.Header().Set("Content-Type", "application/json") + + var req AppConfig + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + Instance().setAppConfig(&req) + + if err := Instance().Persist(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode("ok") +} + +func setAppTitle(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + w.Header().Set("Content-Type", "application/json") + + var req string + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + editField(w, func(c *AppConfig) { + if req != "" { + c.Title = req + } + }) + + json.NewEncoder(w).Encode("ok") +} + +func setBaseURL(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + w.Header().Set("Content-Type", "application/json") + + var req string + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + editField(w, func(c *AppConfig) { + if req != "" { + c.BaseURL = req + } + }) + + json.NewEncoder(w).Encode("ok") +} + +func editField(w http.ResponseWriter, editFunc func(c *AppConfig)) { + editFunc(&Instance().Config) + + if err := Instance().Persist(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func ApplyRouter() func(r chi.Router) { + return func(r chi.Router) { + r.Get("/", GetConfig) + r.Post("/", SetConfig) + r.Patch("/title", setAppTitle) + r.Patch("/baseURL", setBaseURL) + } +} diff --git a/server/server.go b/server/server.go index 429046e..41afecd 100644 --- a/server/server.go +++ b/server/server.go @@ -22,6 +22,7 @@ import ( "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archiver" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" + "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/configurator" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/dbutil" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/filebrowser" "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" @@ -235,6 +236,9 @@ func newServer(c serverConfig) *http.Server { // Subscriptions r.Route("/subscriptions", subscription.Container(c.db, cronTaskRunner).ApplyRouter()) + // Frontend config store + r.Route("/webconfig", configurator.ApplyRouter()) + return &http.Server{Handler: r} } diff --git a/server/subscription/rest/handler.go b/server/subscription/rest/handler.go index 81ffbeb..e419853 100644 --- a/server/subscription/rest/handler.go +++ b/server/subscription/rest/handler.go @@ -48,10 +48,7 @@ func (h *RestHandler) Delete() http.HandlerFunc { return } - if err := json.NewEncoder(w).Encode("ok"); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + w.WriteHeader(http.StatusOK) } }