persist app title + code refactoring

This commit is contained in:
2025-02-13 14:02:00 +01:00
parent 983915f8aa
commit 13c23303a9
15 changed files with 336 additions and 47 deletions

View File

@@ -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": {

View File

@@ -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

View File

@@ -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() {
>
<Menu />
</IconButton>
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
{settings.appTitle}
</Typography>
<AppTitle />
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>

View File

@@ -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<Promise<string>>(async (get) =>
export const cookiesTemplateState = atomWithCache<Promise<string>>(async (get) =>
await get(serverSideCookiesState)
? '--cookies=cookies.txt'
: ''
@@ -22,7 +22,7 @@ export const filenameTemplateState = atomWithStorage(
localStorage.getItem('lastFilenameTemplate') ?? ''
)
export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
export const savedTemplatesState = atomWithCache<Promise<CustomTemplate[]>>(async (get) => {
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
const either = await task()
@@ -30,5 +30,4 @@ export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) =
either,
getOrElse(() => new Array<CustomTemplate>())
)
}
)
})

View File

@@ -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)}`

View File

@@ -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 (
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
{appTitle.startsWith('"') ? appTitle.substring(1, appTitle.length - 1) : appTitle}
</Typography>
)
}

View File

@@ -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<Props> = ({ open, onClose }) => {
const baseURL = useAtomValue(serverURL)
const submit = async (sub: Omit<Subscription, 'id'>) => {
const task = ffetch<void>(`${baseURL}/subscriptions`, {
const submit = async (sub: Omit<Subscription, 'id'>) => pipe(
ffetch<void>(`${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 (
<Dialog

View File

@@ -6,6 +6,12 @@ import { serverURL } from '../atoms/settings'
import { ffetch } from '../lib/httpClient'
import { useToast } from './toast'
/**
* Wrapper hook for ffetch. Handles data retrieval and cancellation signals.
* If R type is set to void it doesn't perform deserialization.
* @param resource path of the resource. serverURL is prepended
* @returns JSON decoded value, eventual error and refetcher as an object to destruct.
*/
const useFetch = <R>(resource: string) => {
const base = useAtomValue(serverURL)
@@ -26,7 +32,10 @@ const useFetch = <R>(resource: string) => {
)()
useEffect(() => {
const controller = new AbortController()
fetcher()
return () => controller.abort()
}, [])
return { data, error, fetcher }

View File

@@ -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<T>(url: string, opt?: RequestInit): Promise<T> {
async function fetcher(url: string, opt?: RequestInit, controller?: AbortController): Promise<string> {
const jwt = localStorage.getItem('token')
if (opt && !opt.headers) {
@@ -14,17 +17,25 @@ async function fetcher<T>(url: string, opt?: RequestInit): Promise<T> {
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 = <T>(url: string, opt?: RequestInit) => tryCatch(
() => fetcher<T>(url, opt),
export const ffetch = <T>(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}`
)

View File

@@ -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<string>(), [])
const appTitle$ = useMemo(() => new Subject<string>(), [])
const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), [])
@@ -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 === ''}
/>
</Grid>
@@ -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')}
/>
/>
}
<FormControlLabel
control={