diff --git a/frontend/package.json b/frontend/package.json
index 77c17b3..614b5b2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,11 +18,11 @@
"@mui/icons-material": "^6.2.0",
"@mui/material": "^6.2.0",
"fp-ts": "^2.16.5",
+ "jotai": "^2.10.3",
"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/src/Layout.tsx b/frontend/src/Layout.tsx
index 0d136bc..61055e0 100644
--- a/frontend/src/Layout.tsx
+++ b/frontend/src/Layout.tsx
@@ -28,6 +28,7 @@ import Footer from './components/Footer'
import Logout from './components/Logout'
import SocketSubscriber from './components/SocketSubscriber'
import ThemeToggler from './components/ThemeToggler'
+import TwitchIcon from './components/TwitchIcon'
import { useI18n } from './hooks/useI18n'
import Toaster from './providers/ToasterProvider'
import { getAccentValue } from './utils'
@@ -154,6 +155,19 @@ export default function Layout() {
+
+
+
+
+
+
+
+
= ({ onDownloadOpen, onEditorOpen }) => {
ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={}
- onClick={onDownloadOpen}
>
: }
diff --git a/frontend/src/components/TwitchIcon.tsx b/frontend/src/components/TwitchIcon.tsx
new file mode 100644
index 0000000..7c7d107
--- /dev/null
+++ b/frontend/src/components/TwitchIcon.tsx
@@ -0,0 +1,22 @@
+import { useAtomValue } from 'jotai'
+import { settingsState } from '../atoms/settings'
+
+const TwitchIcon: React.FC = () => {
+ const { theme } = useAtomValue(settingsState)
+
+ return (
+
+ )
+}
+
+export default TwitchIcon
\ No newline at end of file
diff --git a/frontend/src/components/twitch/TwitchDialog.tsx b/frontend/src/components/twitch/TwitchDialog.tsx
new file mode 100644
index 0000000..95d5399
--- /dev/null
+++ b/frontend/src/components/twitch/TwitchDialog.tsx
@@ -0,0 +1,143 @@
+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 { serverURL } from '../../atoms/settings'
+import { useToast } from '../../hooks/toast'
+import { useI18n } from '../../hooks/useI18n'
+import { ffetch } from '../../lib/httpClient'
+
+type Props = {
+ open: boolean
+ onClose: () => void
+}
+
+const Transition = forwardRef(function Transition(
+ props: TransitionProps & {
+ children: React.ReactElement
+ },
+ ref: React.Ref,
+) {
+ return
+})
+
+const TwitchDialog: React.FC = ({ open, onClose }) => {
+ const [channelURL, setChannelURL] = useState('')
+
+ const { i18n } = useI18n()
+ const { pushMessage } = useToast()
+
+ const baseURL = useAtomValue(serverURL)
+
+ const submit = async (channelURL: string) => {
+ const task = ffetch(`${baseURL}/twitch/user`, {
+ method: 'POST',
+ body: JSON.stringify({
+ user: channelURL.split('/').at(-1)
+ })
+ })
+ const either = await task()
+
+ pipe(
+ either,
+ matchW(
+ (l) => pushMessage(l, 'error'),
+ (_) => onClose()
+ )
+ )
+ }
+
+ return (
+
+ )
+}
+
+export default TwitchDialog
\ No newline at end of file
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 8fbfaa4..d88be34 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -6,6 +6,7 @@ import Terminal from './views/Terminal'
const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login'))
+const Twitch = lazy(() => import('./views/Twitch'))
const Archive = lazy(() => import('./views/Archive'))
const Settings = lazy(() => import('./views/Settings'))
const LiveStream = lazy(() => import('./views/Livestream'))
@@ -111,6 +112,14 @@ export const router = createHashRouter([
)
},
+ {
+ path: '/twitch',
+ element: (
+ }>
+
+
+ )
+ },
]
},
])
\ No newline at end of file
diff --git a/frontend/src/views/Twitch.tsx b/frontend/src/views/Twitch.tsx
new file mode 100644
index 0000000..214c7c5
--- /dev/null
+++ b/frontend/src/views/Twitch.tsx
@@ -0,0 +1,77 @@
+import {
+ Chip,
+ Container,
+ Paper
+} 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 SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial'
+import TwitchDialog from '../components/twitch/TwitchDialog'
+import { useToast } from '../hooks/toast'
+import useFetch from '../hooks/useFetch'
+import { ffetch } from '../lib/httpClient'
+
+const TwitchView: React.FC = () => {
+ const { pushMessage } = useToast()
+
+ const baseURL = useAtomValue(serverURL)
+
+ const [openDialog, setOpenDialog] = useState(false)
+
+ const { data: users, fetcher: refetch } = useFetch>('/twitch/users')
+
+ const [isPending, startTransition] = useTransition()
+
+ const deleteUser = async (user: string) => {
+ const task = ffetch(`${baseURL}/twitch/user/${user}`, {
+ method: 'DELETE',
+ })
+ const either = await task()
+
+ pipe(
+ either,
+ matchW(
+ (l) => pushMessage(l, 'error'),
+ () => refetch()
+ )
+ )
+ }
+
+ return (
+ <>
+
+
+ setOpenDialog(s => !s)} />
+
+ {
+ setOpenDialog(s => !s)
+ refetch()
+ }} />
+
+ {
+ !users || users.length === 0 ?
+ :
+
+
+ {users.map(user => (
+ startTransition(async () => await deleteUser(user))}
+ />
+ ))}
+
+
+ }
+ >
+ )
+}
+
+export default TwitchView
\ No newline at end of file
diff --git a/server/server.go b/server/server.go
index 3a26e00..75bcaa9 100644
--- a/server/server.go
+++ b/server/server.go
@@ -251,8 +251,9 @@ func newServer(c serverConfig) *http.Server {
// Twitch
r.Route("/twitch", func(r chi.Router) {
r.Use(middlewares.ApplyAuthenticationByConfig)
- r.Get("/all", twitch.GetMonitoredUsers(c.tm))
- r.Post("/add", twitch.MonitorUserHandler(c.tm))
+ r.Get("/users", twitch.GetMonitoredUsers(c.tm))
+ r.Post("/user", twitch.MonitorUserHandler(c.tm))
+ r.Delete("/user/{user}", twitch.DeleteUser(c.tm))
})
return &http.Server{Handler: r}
diff --git a/server/twitch/monitor.go b/server/twitch/monitor.go
index c07b984..d803d6d 100644
--- a/server/twitch/monitor.go
+++ b/server/twitch/monitor.go
@@ -83,6 +83,13 @@ func (m *Monitor) GetMonitoredUsers() iter.Seq[string] {
return maps.Keys(m.monitored)
}
+func (m *Monitor) DeleteUser(user string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ delete(m.monitored, user)
+ delete(m.lastState, user)
+}
+
func DEFAULT_DOWNLOAD_HANDLER(db *internal.MemoryDB, mq *internal.MessageQueue) func(url string) error {
return func(url string) error {
p := &internal.Process{
diff --git a/server/twitch/rest.go b/server/twitch/rest.go
index 7b8238b..bb43063 100644
--- a/server/twitch/rest.go
+++ b/server/twitch/rest.go
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"slices"
+
+ "github.com/go-chi/chi/v5"
)
type addUserReq struct {
@@ -32,7 +34,30 @@ func GetMonitoredUsers(m *Monitor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
it := m.GetMonitoredUsers()
- if err := json.NewEncoder(w).Encode(slices.Collect(it)); err != nil {
+ users := slices.Collect(it)
+ if users == nil {
+ users = make([]string, 0)
+ }
+
+ if err := json.NewEncoder(w).Encode(users); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+}
+
+func DeleteUser(m *Monitor) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ user := chi.URLParam(r, "user")
+
+ if user == "" {
+ http.Error(w, "empty user", http.StatusBadRequest)
+ return
+ }
+
+ m.DeleteUser(user)
+
+ if err := json.NewEncoder(w).Encode("ok"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}