mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
first run at mantine
This commit is contained in:
parent
c05e769f09
commit
81978e22d5
68 changed files with 12327 additions and 3 deletions
|
|
@ -6,8 +6,9 @@ services:
|
|||
image: dispatcharr/dispatcharr
|
||||
container_name: dispatcharr_dev
|
||||
ports:
|
||||
- "5656:5656"
|
||||
- 5656:5656
|
||||
- 9191:9191
|
||||
- 8001:8001
|
||||
volumes:
|
||||
- ../:/app
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then
|
|||
echo "export POSTGRES_HOST=$POSTGRES_HOST" >> /etc/profile.d/dispatcharr.sh
|
||||
echo "export POSTGRES_PORT=$POSTGRES_PORT" >> /etc/profile.d/dispatcharr.sh
|
||||
echo "export DISPATCHARR_ENV=$DISPATCHARR_ENV" >> /etc/profile.d/dispatcharr.sh
|
||||
echo "export REACT_APP_ENV_MODE=$DISPATCHARR_ENV" >> /etc/profile.d/dispatcharr.sh
|
||||
fi
|
||||
|
||||
chmod +x /etc/profile.d/dispatcharr.sh
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
attach-daemon = celery -A dispatcharr worker -l info
|
||||
attach-daemon = redis-server
|
||||
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
|
||||
attach-daemon = cd /app/frontend && npm run start
|
||||
attach-daemon = cd /app/vite && npm run dev
|
||||
|
||||
# Core settings
|
||||
chdir = /app
|
||||
|
|
|
|||
24
vite/.gitignore
vendored
Normal file
24
vite/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
vite/README.md
Normal file
12
vite/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
33
vite/eslint.config.js
Normal file
33
vite/eslint.config.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
vite/index.html
Normal file
13
vite/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dispatcharr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4665
vite/package-lock.json
generated
Normal file
4665
vite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
52
vite/package.json
Normal file
52
vite/package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "vite",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mantine/core": "^7.17.1",
|
||||
"@mantine/dates": "^7.17.1",
|
||||
"@mantine/hooks": "^7.17.1",
|
||||
"@mui/icons-material": "^6.4.7",
|
||||
"@mui/material": "^6.4.7",
|
||||
"@mui/x-date-pickers": "^7.27.3",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"axios": "^1.8.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"formik": "^2.4.6",
|
||||
"hls.js": "^1.5.20",
|
||||
"lucide-react": "^0.479.0",
|
||||
"mantine-react-table": "^2.0.0-beta.9",
|
||||
"material-react-table": "^3.2.1",
|
||||
"mpegts.js": "^1.8.0",
|
||||
"prettier": "^3.5.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-pro-sidebar": "^1.1.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"video.js": "^8.21.0",
|
||||
"yup": "^1.6.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
10
vite/prettier.config.js
Normal file
10
vite/prettier.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// prettier.config.js or .prettierrc.js
|
||||
export default {
|
||||
semi: true, // Add semicolons at the end of statements
|
||||
singleQuote: true, // Use single quotes instead of double
|
||||
tabWidth: 2, // Set the indentation width
|
||||
trailingComma: "es5", // Add trailing commas where valid in ES5
|
||||
printWidth: 80, // Wrap lines at 80 characters
|
||||
bracketSpacing: true, // Add spaces inside object braces
|
||||
arrowParens: "always", // Always include parentheses around arrow function parameters
|
||||
};
|
||||
1
vite/public/vite.svg
Normal file
1
vite/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
38
vite/src/App.css
Normal file
38
vite/src/App.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
169
vite/src/App.jsx
Normal file
169
vite/src/App.jsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// frontend/src/App.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Route,
|
||||
Routes,
|
||||
Navigate,
|
||||
} from 'react-router-dom';
|
||||
// import Sidebar from './components/Sidebar';
|
||||
import Sidebar from './components/Sidebar-new';
|
||||
import Login from './pages/Login';
|
||||
import Channels from './pages/Channels';
|
||||
import M3U from './pages/M3U';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { Box, CssBaseline, GlobalStyles } from '@mui/material';
|
||||
import theme from './theme';
|
||||
import EPG from './pages/EPG';
|
||||
import Guide from './pages/Guide';
|
||||
import Settings from './pages/Settings';
|
||||
import StreamProfiles from './pages/StreamProfiles';
|
||||
import useAuthStore from './store/auth';
|
||||
import Alert from './components/Alert';
|
||||
import FloatingVideo from './components/FloatingVideo';
|
||||
import SuperuserForm from './components/forms/SuperuserForm';
|
||||
import { WebsocketProvider } from './WebSocket';
|
||||
import { AppShell, MantineProvider } from '@mantine/core';
|
||||
import '@mantine/core/styles.css'; // Ensure Mantine global styles load
|
||||
import 'mantine-react-table/styles.css';
|
||||
import mantineTheme from './mantineTheme';
|
||||
|
||||
const drawerWidth = 240;
|
||||
const miniDrawerWidth = 60;
|
||||
const defaultRoute = '/channels';
|
||||
|
||||
const App = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [needsSuperuser, setNeedsSuperuser] = useState(false);
|
||||
const {
|
||||
isAuthenticated,
|
||||
setIsAuthenticated,
|
||||
logout,
|
||||
initData,
|
||||
initializeAuth,
|
||||
} = useAuthStore();
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
// Check if a superuser exists on first load.
|
||||
useEffect(() => {
|
||||
async function checkSuperuser() {
|
||||
try {
|
||||
const response = await fetch('/api/accounts/initialize-superuser/');
|
||||
const res = await response.json();
|
||||
if (!res.data.superuser_exists) {
|
||||
setNeedsSuperuser(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking superuser status:', error);
|
||||
}
|
||||
}
|
||||
checkSuperuser();
|
||||
}, []);
|
||||
|
||||
// Authentication check
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const loggedIn = await initializeAuth();
|
||||
if (loggedIn) {
|
||||
await initData();
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
await logout();
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [initializeAuth, initData, setIsAuthenticated, logout]);
|
||||
|
||||
// If no superuser exists, show the initialization form
|
||||
if (needsSuperuser) {
|
||||
return <SuperuserForm onSuccess={() => setNeedsSuperuser(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
defaultColorScheme="dark"
|
||||
theme={mantineTheme}
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
'.Mui-TableHeadCell-Content': {
|
||||
height: '100%',
|
||||
alignItems: 'flex-end !important',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<WebsocketProvider>
|
||||
<Router>
|
||||
<AppShell
|
||||
header={{
|
||||
height: 0,
|
||||
}}
|
||||
navbar={{
|
||||
width: open ? drawerWidth : miniDrawerWidth,
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
drawerWidth
|
||||
miniDrawerWidth
|
||||
collapsed={!open}
|
||||
toggleDrawer={toggleDrawer}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
|
||||
// transition: 'margin-left 0.3s',
|
||||
backgroundColor: 'background.default',
|
||||
minHeight: '100vh',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
|
||||
<Routes>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/m3u" element={<M3U />} />
|
||||
<Route path="/epg" element={<EPG />} />
|
||||
<Route
|
||||
path="/stream-profiles"
|
||||
element={<StreamProfiles />}
|
||||
/>
|
||||
<Route path="/guide" element={<Guide />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="/login" element={<Login />} />
|
||||
)}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Navigate
|
||||
to={isAuthenticated ? defaultRoute : '/login'}
|
||||
replace
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
</AppShell>
|
||||
</Router>
|
||||
|
||||
<Alert />
|
||||
<FloatingVideo />
|
||||
</WebsocketProvider>
|
||||
</ThemeProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
85
vite/src/WebSocket.jsx
Normal file
85
vite/src/WebSocket.jsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
createContext,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import useStreamsStore from './store/streams';
|
||||
import useAlertStore from './store/alerts';
|
||||
|
||||
export const WebsocketContext = createContext(false, null, () => {});
|
||||
|
||||
export const WebsocketProvider = ({ children }) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [val, setVal] = useState(null);
|
||||
|
||||
const { showAlert } = useAlertStore();
|
||||
const { fetchStreams } = useStreamsStore();
|
||||
|
||||
const ws = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let wsUrl = `${window.location.host}/ws/`;
|
||||
if (import.meta.env.DEV) {
|
||||
wsUrl = `${window.location.hostname}:8001/ws/`;
|
||||
}
|
||||
|
||||
if (window.location.protocol.match(/https/)) {
|
||||
wsUrl = `wss://${wsUrl}`;
|
||||
} else {
|
||||
wsUrl = `ws://${wsUrl}`;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('websocket connected');
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
// Reconnection logic
|
||||
socket.onclose = () => {
|
||||
setIsReady(false);
|
||||
setTimeout(() => {
|
||||
const reconnectWs = new WebSocket(wsUrl);
|
||||
reconnectWs.onopen = () => setIsReady(true);
|
||||
}, 3000); // Attempt to reconnect every 3 seconds
|
||||
};
|
||||
|
||||
socket.onmessage = async (event) => {
|
||||
event = JSON.parse(event.data);
|
||||
switch (event.type) {
|
||||
case 'm3u_refresh':
|
||||
if (event.message?.success) {
|
||||
fetchStreams();
|
||||
showAlert(event.message.message, 'success');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown websocket event type: ${event.type}`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.current = socket;
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ret = [isReady, val, ws.current?.send.bind(ws.current)];
|
||||
|
||||
return (
|
||||
<WebsocketContext.Provider value={ret}>
|
||||
{children}
|
||||
</WebsocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useWebSocket = () => {
|
||||
const socket = useContext(WebsocketContext);
|
||||
return socket;
|
||||
};
|
||||
783
vite/src/api.js
Normal file
783
vite/src/api.js
Normal file
|
|
@ -0,0 +1,783 @@
|
|||
// src/api.js (updated)
|
||||
import useAuthStore from './store/auth';
|
||||
import useChannelsStore from './store/channels';
|
||||
import useUserAgentsStore from './store/userAgents';
|
||||
import usePlaylistsStore from './store/playlists';
|
||||
import useEPGsStore from './store/epgs';
|
||||
import useStreamsStore from './store/streams';
|
||||
import useStreamProfilesStore from './store/streamProfiles';
|
||||
import useSettingsStore from './store/settings';
|
||||
|
||||
// If needed, you can set a base host or keep it empty if relative requests
|
||||
const host = import.meta.env.DEV
|
||||
? `http://${window.location.hostname}:5656`
|
||||
: '';
|
||||
|
||||
export default class API {
|
||||
/**
|
||||
* A static method so we can do: await API.getAuthToken()
|
||||
*/
|
||||
static async getAuthToken() {
|
||||
return await useAuthStore.getState().getToken();
|
||||
}
|
||||
|
||||
static async login(username, password) {
|
||||
const response = await fetch(`${host}/api/accounts/token/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
static async refreshToken(refresh) {
|
||||
const response = await fetch(`${host}/api/accounts/token/refresh/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh }),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async logout() {
|
||||
const response = await fetch(`${host}/api/accounts/auth/logout/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
static async getChannels() {
|
||||
const response = await fetch(`${host}/api/channels/channels/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getChannelGroups() {
|
||||
const response = await fetch(`${host}/api/channels/groups/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addChannelGroup(values) {
|
||||
const response = await fetch(`${host}/api/channels/groups/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().addChannelGroup(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async updateChannelGroup(values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(`${host}/api/channels/groups/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().updateChannelGroup(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addChannel(channel) {
|
||||
let body = null;
|
||||
if (channel.logo_file) {
|
||||
// Must send FormData for file upload
|
||||
body = new FormData();
|
||||
for (const prop in channel) {
|
||||
body.append(prop, channel[prop]);
|
||||
}
|
||||
} else {
|
||||
body = { ...channel };
|
||||
delete body.logo_file;
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${host}/api/channels/channels/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
...(channel.logo_file
|
||||
? {}
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().addChannel(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteChannel(id) {
|
||||
const response = await fetch(`${host}/api/channels/channels/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useChannelsStore.getState().removeChannels([id]);
|
||||
}
|
||||
|
||||
// @TODO: the bulk delete endpoint is currently broken
|
||||
static async deleteChannels(channel_ids) {
|
||||
const response = await fetch(`${host}/api/channels/channels/bulk-delete/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ channel_ids }),
|
||||
});
|
||||
|
||||
useChannelsStore.getState().removeChannels(channel_ids);
|
||||
}
|
||||
|
||||
static async updateChannel(values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(`${host}/api/channels/channels/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().updateChannel(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async assignChannelNumbers(channelIds) {
|
||||
// Make the request
|
||||
const response = await fetch(`${host}/api/channels/channels/assign/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ channel_order: channelIds }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Assign channels failed: ${response.status} => ${text}`);
|
||||
}
|
||||
|
||||
const retval = await response.json();
|
||||
|
||||
// Optionally refresh the channel list in Zustand
|
||||
await useChannelsStore.getState().fetchChannels();
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async createChannelFromStream(values) {
|
||||
const response = await fetch(`${host}/api/channels/channels/from-stream/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().addChannel(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async createChannelsFromStreams(values) {
|
||||
const response = await fetch(
|
||||
`${host}/api/channels/channels/from-stream/bulk/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
}
|
||||
);
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.created.length > 0) {
|
||||
useChannelsStore.getState().addChannels(retval.created);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getStreams() {
|
||||
const response = await fetch(`${host}/api/channels/streams/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async queryStreams(params) {
|
||||
const response = await fetch(
|
||||
`${host}/api/channels/streams/?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getAllStreamIds(params) {
|
||||
const response = await fetch(
|
||||
`${host}/api/channels/streams/ids/?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getStreamGroups() {
|
||||
const response = await fetch(`${host}/api/channels/streams/groups/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addStream(values) {
|
||||
const response = await fetch(`${host}/api/channels/streams/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useStreamsStore.getState().addStream(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async updateStream(values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(`${host}/api/channels/streams/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useStreamsStore.getState().updateStream(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteStream(id) {
|
||||
const response = await fetch(`${host}/api/channels/streams/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useStreamsStore.getState().removeStreams([id]);
|
||||
}
|
||||
|
||||
static async deleteStreams(ids) {
|
||||
const response = await fetch(`${host}/api/channels/streams/bulk-delete/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ stream_ids: ids }),
|
||||
});
|
||||
|
||||
useStreamsStore.getState().removeStreams(ids);
|
||||
}
|
||||
|
||||
static async getUserAgents() {
|
||||
const response = await fetch(`${host}/api/core/useragents/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addUserAgent(values) {
|
||||
const response = await fetch(`${host}/api/core/useragents/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useUserAgentsStore.getState().addUserAgent(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async updateUserAgent(values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useUserAgentsStore.getState().updateUserAgent(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteUserAgent(id) {
|
||||
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useUserAgentsStore.getState().removeUserAgents([id]);
|
||||
}
|
||||
|
||||
static async getPlaylist(id) {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getPlaylists() {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addPlaylist(values) {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
usePlaylistsStore.getState().addPlaylist(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async refreshPlaylist(id) {
|
||||
const response = await fetch(`${host}/api/m3u/refresh/${id}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async refreshAllPlaylist() {
|
||||
const response = await fetch(`${host}/api/m3u/refresh/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deletePlaylist(id) {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
usePlaylistsStore.getState().removePlaylists([id]);
|
||||
}
|
||||
|
||||
static async updatePlaylist(values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
usePlaylistsStore.getState().updatePlaylist(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getEPGs() {
|
||||
const response = await fetch(`${host}/api/epg/sources/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
// Notice there's a duplicated "refreshPlaylist" method above;
|
||||
// you might want to rename or remove one if it's not needed.
|
||||
|
||||
static async addEPG(values) {
|
||||
let body = null;
|
||||
if (values.epg_file) {
|
||||
body = new FormData();
|
||||
for (const prop in values) {
|
||||
body.append(prop, values[prop]);
|
||||
}
|
||||
} else {
|
||||
body = { ...values };
|
||||
delete body.epg_file;
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${host}/api/epg/sources/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
...(values.epg_file
|
||||
? {}
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useEPGsStore.getState().addEPG(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteEPG(id) {
|
||||
const response = await fetch(`${host}/api/epg/sources/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useEPGsStore.getState().removeEPGs([id]);
|
||||
}
|
||||
|
||||
static async refreshEPG(id) {
|
||||
const response = await fetch(`${host}/api/epg/import/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getStreamProfiles() {
|
||||
const response = await fetch(`${host}/api/core/streamprofiles/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addStreamProfile(values) {
|
||||
const response = await fetch(`${host}/api/core/streamprofiles/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useStreamProfilesStore.getState().addStreamProfile(retval);
|
||||
}
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async updateStreamProfile(values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useStreamProfilesStore.getState().updateStreamProfile(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteStreamProfile(id) {
|
||||
const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useStreamProfilesStore.getState().removeStreamProfiles([id]);
|
||||
}
|
||||
|
||||
static async getGrid() {
|
||||
const response = await fetch(`${host}/api/epg/grid/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval.data;
|
||||
}
|
||||
|
||||
static async addM3UProfile(accountId, values) {
|
||||
const response = await fetch(
|
||||
`${host}/api/m3u/accounts/${accountId}/profiles/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
}
|
||||
);
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
// Refresh the playlist
|
||||
const playlist = await API.getPlaylist(accountId);
|
||||
usePlaylistsStore
|
||||
.getState()
|
||||
.updateProfiles(playlist.id, playlist.profiles);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteM3UProfile(accountId, id) {
|
||||
const response = await fetch(
|
||||
`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const playlist = await API.getPlaylist(accountId);
|
||||
usePlaylistsStore.getState().updatePlaylist(playlist);
|
||||
}
|
||||
|
||||
static async updateM3UProfile(accountId, values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(
|
||||
`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
const playlist = await API.getPlaylist(accountId);
|
||||
usePlaylistsStore.getState().updateProfiles(playlist.id, playlist.profiles);
|
||||
}
|
||||
|
||||
static async getSettings() {
|
||||
const response = await fetch(`${host}/api/core/settings/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getEnvironmentSettings() {
|
||||
const response = await fetch(`${host}/api/core/settings/env/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async updateSetting(values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(`${host}/api/core/settings/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useSettingsStore.getState().updateSetting(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
}
|
||||
1
vite/src/assets/react.svg
Normal file
1
vite/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
26
vite/src/components/Alert.jsx
Normal file
26
vite/src/components/Alert.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Snackbar, Alert, Button } from '@mui/material';
|
||||
import useAlertStore from '../store/alerts';
|
||||
|
||||
const AlertPopup = () => {
|
||||
const { open, message, severity, hideAlert } = useAlertStore();
|
||||
|
||||
const handleClose = () => {
|
||||
hideAlert();
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={5000}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertPopup;
|
||||
98
vite/src/components/FloatingVideo.jsx
Normal file
98
vite/src/components/FloatingVideo.jsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// frontend/src/components/FloatingVideo.js
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Draggable from 'react-draggable';
|
||||
import useVideoStore from '../store/useVideoStore';
|
||||
import mpegts from 'mpegts.js';
|
||||
|
||||
export default function FloatingVideo() {
|
||||
const { isVisible, streamUrl, hideVideo } = useVideoStore();
|
||||
const videoRef = useRef(null);
|
||||
const playerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !streamUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the browser supports MSE for live playback, initialize mpegts.js
|
||||
if (mpegts.getFeatureList().mseLivePlayback) {
|
||||
const player = mpegts.createPlayer({
|
||||
type: 'mpegts',
|
||||
url: streamUrl,
|
||||
isLive: true,
|
||||
// You can include other custom MPEGTS.js config fields here, e.g.:
|
||||
// cors: true,
|
||||
// withCredentials: false,
|
||||
});
|
||||
|
||||
player.attachMediaElement(videoRef.current);
|
||||
player.load();
|
||||
player.play();
|
||||
|
||||
// Store player instance so we can clean up later
|
||||
playerRef.current = player;
|
||||
}
|
||||
|
||||
// Cleanup when component unmounts or streamUrl changes
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.destroy();
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isVisible, streamUrl]);
|
||||
|
||||
// If the floating video is hidden or no URL is selected, do not render
|
||||
if (!isVisible || !streamUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Draggable>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
width: '320px',
|
||||
zIndex: 9999,
|
||||
backgroundColor: '#333',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.7)',
|
||||
}}
|
||||
>
|
||||
{/* Simple header row with a close button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={hideVideo}
|
||||
style={{
|
||||
background: 'red',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
padding: '2px 8px',
|
||||
}}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The <video> element used by mpegts.js */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
style={{ width: '100%', height: '180px', backgroundColor: '#000' }}
|
||||
/>
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
186
vite/src/components/Sidebar-new.jsx
Normal file
186
vite/src/components/Sidebar-new.jsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ListOrdered,
|
||||
Play,
|
||||
Database,
|
||||
SlidersHorizontal,
|
||||
LayoutGrid,
|
||||
Settings as LucideSettings,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar,
|
||||
AppShell,
|
||||
Group,
|
||||
Stack,
|
||||
Box,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import headerLogo from '../images/dispatcharr.svg';
|
||||
import logo from '../images/logo.png';
|
||||
|
||||
// Navigation Items
|
||||
const navItems = [
|
||||
{
|
||||
label: 'Channels',
|
||||
icon: <ListOrdered size={20} />,
|
||||
path: '/channels',
|
||||
// badge: '(323)',
|
||||
},
|
||||
{ label: 'M3U', icon: <Play size={20} />, path: '/m3u' },
|
||||
{ label: 'EPG', icon: <Database size={20} />, path: '/epg' },
|
||||
{
|
||||
label: 'Stream Profiles',
|
||||
icon: <SlidersHorizontal size={20} />,
|
||||
path: '/stream-profiles',
|
||||
},
|
||||
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
|
||||
{ label: 'Settings', icon: <LucideSettings size={20} />, path: '/settings' },
|
||||
];
|
||||
|
||||
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<AppShell.Navbar
|
||||
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
|
||||
p="xs"
|
||||
style={{
|
||||
backgroundColor: '#1A1A1E',
|
||||
// transition: 'width 0.3s ease',
|
||||
borderRight: '1px solid #2A2A2E',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Brand - Click to Toggle */}
|
||||
<Group
|
||||
onClick={toggleDrawer}
|
||||
spacing="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '16px 12px',
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: '#FFFFFF',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{/* <ListOrdered size={24} /> */}
|
||||
<img width={30} src={logo} />
|
||||
{!collapsed && (
|
||||
<Text
|
||||
sx={{
|
||||
opacity: collapsed ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
whiteSpace: 'nowrap', // Ensures text never wraps
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: collapsed ? 0 : 150, // Prevents reflow
|
||||
}}
|
||||
>
|
||||
Dispatcharr
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<Stack spacing="sm" mt="lg">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={item.path}
|
||||
component={Link}
|
||||
to={item.path}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row', // Ensures horizontal layout
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: collapsed ? '5px 8px' : '10px 16px',
|
||||
borderRadius: 6,
|
||||
color: isActive ? '#FFFFFF' : '#D4D4D8',
|
||||
backgroundColor: isActive ? '#245043' : 'transparent',
|
||||
border: isActive
|
||||
? '1px solid #3BA882'
|
||||
: '1px solid transparent',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: isActive ? '#3A3A40' : '#2A2F34', // Gray hover effect when active
|
||||
border: isActive ? '1px solid #3BA882' : '1px solid #3D3D42',
|
||||
},
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && (
|
||||
<Text
|
||||
sx={{
|
||||
opacity: collapsed ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: collapsed ? 0 : 150,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
{!collapsed && item.badge && (
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ color: '#D4D4D8', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{item.badge}
|
||||
</Text>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Profile Section */}
|
||||
<Box
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
borderTop: '1px solid #2A2A2E',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Avatar src="https://via.placeholder.com/40" radius="xl" />
|
||||
{!collapsed && (
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" color="white">
|
||||
John Doe
|
||||
</Text>
|
||||
<Text size="sm" color="white">
|
||||
•••
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</AppShell.Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
189
vite/src/components/Sidebar.jsx
Normal file
189
vite/src/components/Sidebar.jsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
Toolbar,
|
||||
Box,
|
||||
Typography,
|
||||
Avatar,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
ListOrdered,
|
||||
Play,
|
||||
Database,
|
||||
SlidersHorizontal,
|
||||
LayoutGrid,
|
||||
Settings as LucideSettings,
|
||||
} from 'lucide-react';
|
||||
import logo from '../images/logo.png';
|
||||
import { AppShell } from '@mantine/core';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Channels', icon: <ListOrdered size={20} />, path: '/channels' },
|
||||
{ label: 'M3U', icon: <Play size={20} />, path: '/m3u' },
|
||||
{ label: 'EPG', icon: <Database size={20} />, path: '/epg' },
|
||||
{
|
||||
label: 'Stream Profiles',
|
||||
icon: <SlidersHorizontal size={20} />,
|
||||
path: '/stream-profiles',
|
||||
},
|
||||
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
|
||||
{ label: 'Settings', icon: <LucideSettings size={20} />, path: '/settings' },
|
||||
];
|
||||
|
||||
const Sidebar = ({ open, drawerWidth, miniDrawerWidth, toggleDrawer }) => {
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<AppShell.Navbar>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: open ? drawerWidth : miniDrawerWidth,
|
||||
overflowX: 'hidden',
|
||||
transition: 'width 0.3s',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: 'text.primary',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: 'none',
|
||||
border: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: open ? 'space-between' : 'center',
|
||||
minHeight: '64px !important',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
{open ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt="Dispatcharr Logo"
|
||||
style={{ width: 28, height: 'auto' }}
|
||||
/>
|
||||
<Typography variant="h6" noWrap sx={{ color: 'text.primary' }}>
|
||||
Dispatcharr
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<img
|
||||
src={logo}
|
||||
alt="Dispatcharr Logo"
|
||||
style={{ width: 28, height: 'auto', cursor: 'pointer' }}
|
||||
onClick={toggleDrawer}
|
||||
/>
|
||||
)}
|
||||
</Toolbar>
|
||||
|
||||
<List disablePadding sx={{ pt: 0 }}>
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
component={Link}
|
||||
to={item.path}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
mx: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
color: 'inherit',
|
||||
width: '100%',
|
||||
'&:hover': { backgroundColor: 'unset !important' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 1,
|
||||
width: open ? '208px' : 'auto',
|
||||
transition: 'all 0.2s ease',
|
||||
bgcolor: isActive
|
||||
? theme.custom.sidebar.activeBackground
|
||||
: 'transparent',
|
||||
border: isActive
|
||||
? `1px solid ${theme.custom.sidebar.activeBorder}`
|
||||
: '1px solid transparent',
|
||||
color: 'text.primary',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
'&:hover': {
|
||||
bgcolor: theme.custom.sidebar.hoverBackground,
|
||||
border: `1px solid ${theme.custom.sidebar.hoverBorder}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
minWidth: 0,
|
||||
mr: open ? 1 : 'auto',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
{open && (
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
sx: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 400,
|
||||
fontFamily: theme.custom.sidebar.fontFamily,
|
||||
letterSpacing: '-0.3px',
|
||||
// Keeping the text color as it is in your original
|
||||
color: isActive ? '#d4d4d8' : '#d4d4d8',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Avatar
|
||||
alt="John Doe"
|
||||
src="/static/images/avatar.png"
|
||||
sx={{ width: 32, height: 32 }}
|
||||
/>
|
||||
{open && (
|
||||
<Typography variant="body2" noWrap sx={{ color: 'text.primary' }}>
|
||||
John Doe
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
</AppShell.Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
491
vite/src/components/forms/Channel.jsx
Normal file
491
vite/src/components/forms/Channel.jsx
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Stack,
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
Grid2,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormHelperText,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
|
||||
import useStreamsStore from '../../store/streams';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table';
|
||||
import ChannelGroupForm from './ChannelGroup';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import logo from '../../images/logo.png';
|
||||
|
||||
const Channel = ({ channel = null, isOpen, onClose }) => {
|
||||
const channelGroups = useChannelsStore((state) => state.channelGroups);
|
||||
const streams = useStreamsStore((state) => state.streams);
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
const { playlists } = usePlaylistsStore();
|
||||
|
||||
const [logoFile, setLogoFile] = useState(null);
|
||||
const [logoPreview, setLogoPreview] = useState(logo);
|
||||
const [channelStreams, setChannelStreams] = useState([]);
|
||||
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
|
||||
|
||||
const addStream = (stream) => {
|
||||
const streamSet = new Set(channelStreams);
|
||||
streamSet.add(stream);
|
||||
setChannelStreams(Array.from(streamSet));
|
||||
};
|
||||
|
||||
const removeStream = (stream) => {
|
||||
const streamSet = new Set(channelStreams);
|
||||
streamSet.delete(stream);
|
||||
setChannelStreams(Array.from(streamSet));
|
||||
};
|
||||
|
||||
const handleLogoChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setLogoFile(file);
|
||||
setLogoPreview(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
channel_name: '',
|
||||
channel_number: '',
|
||||
channel_group_id: '',
|
||||
stream_profile_id: '0',
|
||||
tvg_id: '',
|
||||
tvg_name: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
channel_name: Yup.string().required('Name is required'),
|
||||
channel_number: Yup.string().required('Invalid channel number').min(0),
|
||||
channel_group_id: Yup.string().required('Channel group is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (values.stream_profile_id == '0') {
|
||||
values.stream_profile_id = null;
|
||||
}
|
||||
|
||||
console.log(values);
|
||||
if (channel?.id) {
|
||||
await API.updateChannel({
|
||||
id: channel.id,
|
||||
...values,
|
||||
logo_file: logoFile,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
} else {
|
||||
await API.addChannel({
|
||||
...values,
|
||||
logo_file: logoFile,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setLogoFile(null);
|
||||
setLogoPreview(logo);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
formik.setValues({
|
||||
channel_name: channel.channel_name,
|
||||
channel_number: channel.channel_number,
|
||||
channel_group_id: channel.channel_group?.id,
|
||||
stream_profile_id: channel.stream_profile_id || '0',
|
||||
tvg_id: channel.tvg_id,
|
||||
tvg_name: channel.tvg_name,
|
||||
});
|
||||
|
||||
console.log(channel);
|
||||
const filteredStreams = streams
|
||||
.filter((stream) => channel.stream_ids.includes(stream.id))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
channel.stream_ids.indexOf(a.id) - channel.stream_ids.indexOf(b.id)
|
||||
);
|
||||
setChannelStreams(filteredStreams);
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
const activeStreamsTable = useMaterialReactTable({
|
||||
data: channelStreams,
|
||||
columns: useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
accessorKey: 'group_name',
|
||||
},
|
||||
],
|
||||
[]
|
||||
),
|
||||
enableSorting: false,
|
||||
enableBottomToolbar: false,
|
||||
enableTopToolbar: false,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowOrdering: true,
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: 'last',
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => removeStream(row.original)}
|
||||
>
|
||||
<RemoveIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: '200px',
|
||||
},
|
||||
},
|
||||
muiRowDragHandleProps: ({ table }) => ({
|
||||
onDragEnd: () => {
|
||||
const { draggingRow, hoveredRow } = table.getState();
|
||||
|
||||
if (hoveredRow && draggingRow) {
|
||||
channelStreams.splice(
|
||||
hoveredRow.index,
|
||||
0,
|
||||
channelStreams.splice(draggingRow.index, 1)[0]
|
||||
);
|
||||
|
||||
setChannelStreams([...channelStreams]);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const availableStreamsTable = useMaterialReactTable({
|
||||
data: streams,
|
||||
columns: useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
accessorFn: (row) =>
|
||||
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
},
|
||||
],
|
||||
[]
|
||||
),
|
||||
enableBottomToolbar: false,
|
||||
enableTopToolbar: false,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
onClick={() => addStream(row.original)}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
positionActionsColumn: 'last',
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: '200px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onClose={onClose} fullWidth maxWidth="lg">
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
Channel
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="channel_name"
|
||||
name="channel_name"
|
||||
label="Channel Name"
|
||||
value={formik.values.channel_name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.channel_name &&
|
||||
Boolean(formik.errors.channel_name)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.channel_name && formik.errors.channel_name
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<Grid2
|
||||
container
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Grid2 size={11}>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="channel-group-label">
|
||||
Channel Group
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="channel-group-label"
|
||||
id="channel_group_id"
|
||||
name="channel_group_id"
|
||||
label="Channel Group"
|
||||
value={formik.values.channel_group_id}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.channel_group_id &&
|
||||
Boolean(formik.errors.channel_group_id)
|
||||
}
|
||||
// helperText={formik.touched.channel_group_id && formik.errors.channel_group_id}
|
||||
variant="standard"
|
||||
>
|
||||
{channelGroups.map((option, index) => (
|
||||
<MenuItem key={index} value={option.id}>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText sx={{ color: 'error.main' }}>
|
||||
{formik.touched.channel_group_id &&
|
||||
formik.errors.channel_group_id}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid2>
|
||||
<Grid2 size={1}>
|
||||
<IconButton
|
||||
color="success"
|
||||
onClick={() => setChannelGroupModalOpen(true)}
|
||||
title="Create new group"
|
||||
size="small"
|
||||
variant="filled"
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="stream-profile-label">
|
||||
Stream Profile
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="stream-profile-label"
|
||||
id="stream_profile_id"
|
||||
name="stream_profile_id"
|
||||
value={formik.values.stream_profile_id}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.stream_profile_id &&
|
||||
Boolean(formik.errors.stream_profile_id)
|
||||
}
|
||||
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
|
||||
variant="standard"
|
||||
>
|
||||
<MenuItem value="0" selected>
|
||||
<em>Use Default</em>
|
||||
</MenuItem>
|
||||
{streamProfiles.map((option, index) => (
|
||||
<MenuItem key={index} value={option.id}>
|
||||
{option.profile_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="channel_number"
|
||||
name="channel_number"
|
||||
label="Channel #"
|
||||
value={formik.values.channel_number}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.channel_number &&
|
||||
Boolean(formik.errors.channel_number)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.channel_number &&
|
||||
formik.errors.channel_number
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
</Grid2>
|
||||
|
||||
<Grid2 size={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="tvg_name"
|
||||
name="tvg_name"
|
||||
label="TVG Name"
|
||||
value={formik.values.tvg_name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.tvg_name && Boolean(formik.errors.tvg_name)
|
||||
}
|
||||
helperText={formik.touched.tvg_name && formik.errors.tvg_name}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="tvg_id"
|
||||
name="tvg_id"
|
||||
label="TVG ID"
|
||||
value={formik.values.tvg_id}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.tvg_id && Boolean(formik.errors.tvg_id)}
|
||||
helperText={formik.touched.tvg_id && formik.errors.tvg_id}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="logo_url"
|
||||
name="logo_url"
|
||||
label="Logo URL (Optional)"
|
||||
variant="standard"
|
||||
sx={{ marginBottom: 2 }}
|
||||
value={formik.values.logo_url}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
helperText="If you have a direct image URL, set it here."
|
||||
/>
|
||||
|
||||
<Box mt={2} mb={2}>
|
||||
{/* File upload input */}
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography>Logo</Typography>
|
||||
{/* Display selected image */}
|
||||
<Box mb={2}>
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Selected"
|
||||
style={{ maxWidth: 50, height: 'auto' }}
|
||||
/>
|
||||
</Box>
|
||||
<input
|
||||
type="file"
|
||||
id="logo"
|
||||
name="logo"
|
||||
accept="image/*"
|
||||
onChange={(event) => handleLogoChange(event)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<label htmlFor="logo">
|
||||
<Button variant="contained" component="span" size="small">
|
||||
Browse...
|
||||
</Button>
|
||||
</label>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={6}>
|
||||
<Typography>Active Streams</Typography>
|
||||
<MaterialReactTable table={activeStreamsTable} />
|
||||
</Grid2>
|
||||
|
||||
<Grid2 size={6}>
|
||||
<Typography>Available Streams</Typography>
|
||||
<MaterialReactTable table={availableStreamsTable} />
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
<ChannelGroupForm
|
||||
isOpen={channelGroupModelOpen}
|
||||
onClose={() => setChannelGroupModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Channel;
|
||||
71
vite/src/components/forms/ChannelGroup.jsx
Normal file
71
vite/src/components/forms/ChannelGroup.jsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import { Flex, TextInput, Button } from '@mantine/core';
|
||||
|
||||
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (channelGroup?.id) {
|
||||
await API.updateChannelGroup({ id: channelGroup.id, ...values });
|
||||
} else {
|
||||
await API.addChannelGroup(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (channelGroup) {
|
||||
formik.setValues({
|
||||
name: channelGroup.name,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [channelGroup]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Channel Group">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.name}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelGroup;
|
||||
143
vite/src/components/forms/EPG.jsx
Normal file
143
vite/src/components/forms/EPG.jsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
|
||||
const EPG = ({ epg = null, isOpen, onClose }) => {
|
||||
const epgs = useEPGsStore((state) => state.epgs);
|
||||
const [file, setFile] = useState(null);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
source_type: 'xmltv',
|
||||
url: '',
|
||||
api_key: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
source_type: Yup.string().required('Source type is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (epg?.id) {
|
||||
await API.updateEPG({ id: epg.id, ...values, epg_file: file });
|
||||
} else {
|
||||
await API.addEPG({
|
||||
...values,
|
||||
epg_file: file,
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setFile(null);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (epg) {
|
||||
formik.setValues({
|
||||
name: epg.name,
|
||||
source_type: epg.source_type,
|
||||
url: epg.url,
|
||||
api_key: epg.api_key,
|
||||
is_active: epg.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [epg]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="EPG Source">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
value={formik.values.url}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.url && Boolean(formik.errors.url)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
value={formik.values.api_key}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.api_key && Boolean(formik.errors.api_key)}
|
||||
/>
|
||||
|
||||
<NativeSelect
|
||||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
value={formik.values.source_type}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.touched.source_type && Boolean(formik.errors.source_type)
|
||||
}
|
||||
data={[
|
||||
{
|
||||
label: 'XMLTV',
|
||||
valeu: 'xmltv',
|
||||
},
|
||||
{
|
||||
label: 'Schedules Direct',
|
||||
valeu: 'schedules_direct',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPG;
|
||||
93
vite/src/components/forms/LoginForm.jsx
Normal file
93
vite/src/components/forms/LoginForm.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import {
|
||||
Paper,
|
||||
Title,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Box,
|
||||
Center,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext
|
||||
const navigate = useNavigate(); // Hook to navigate to other routes
|
||||
const [formData, setFormData] = useState({ username: '', password: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/channels');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
await login(formData);
|
||||
initData();
|
||||
navigate('/channels'); // Or any other route you'd like
|
||||
};
|
||||
|
||||
// // Handle form submission
|
||||
// const handleSubmit = async (e) => {
|
||||
// e.preventDefault();
|
||||
// setLoading(true);
|
||||
// setError(''); // Reset error on each new submission
|
||||
|
||||
// await login(username, password)
|
||||
// navigate('/channels'); // Or any other route you'd like
|
||||
// };
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{ padding: 30, width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Title order={4} align="center">
|
||||
Login
|
||||
</Title>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Password"
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" size="sm" sx={{ pt: 1 }}>
|
||||
Submit
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
192
vite/src/components/forms/M3U.jsx
Normal file
192
vite/src/components/forms/M3U.jsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import M3UProfiles from './M3UProfiles';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Select,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
|
||||
const M3U = ({ playlist = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
const [file, setFile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
|
||||
const handleFileChange = (file) => {
|
||||
console.log(file);
|
||||
if (file) {
|
||||
setFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
server_url: '',
|
||||
user_agent: '',
|
||||
is_active: true,
|
||||
max_streams: 0,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
max_streams: Yup.string().required('Max streams is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (playlist?.id) {
|
||||
await API.updatePlaylist({
|
||||
id: playlist.id,
|
||||
...values,
|
||||
uploaded_file: file,
|
||||
});
|
||||
} else {
|
||||
await API.addPlaylist({
|
||||
...values,
|
||||
uploaded_file: file,
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setFile(null);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (playlist) {
|
||||
formik.setValues({
|
||||
name: playlist.name,
|
||||
server_url: playlist.server_url,
|
||||
max_streams: playlist.max_streams,
|
||||
user_agent: playlist.user_agent,
|
||||
is_active: playlist.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [playlist]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
console.log(formik.values);
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="M3U Account">
|
||||
<div style={{ width: 400, position: 'relative' }}>
|
||||
<LoadingOverlay visible={formik.isSubmitting} overlayBlur={2} />
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
fullWidth
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
helperText={formik.touched.name && formik.errors.name}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
fullWidth
|
||||
id="server_url"
|
||||
name="server_url"
|
||||
label="URL"
|
||||
value={formik.values.server_url}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.touched.server_url && Boolean(formik.errors.server_url)
|
||||
}
|
||||
helperText={formik.touched.server_url && formik.errors.server_url}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
id="uploaded_file"
|
||||
label="Upload files"
|
||||
placeholder="Upload files"
|
||||
value={formik.uploaded_file}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
fullWidth
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams (0 = unlimited)"
|
||||
value={formik.values.max_streams}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
|
||||
/>
|
||||
|
||||
<NativeSelect
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent.value}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.user_agent ? formik.touched.user_agent : ''}
|
||||
data={userAgents.map((ua) => ({
|
||||
label: ua.user_agent_name,
|
||||
value: `${ua.id}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Checkbox
|
||||
label="Is Active"
|
||||
name="is_active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={(e) =>
|
||||
formik.setFieldValue('is_active', e.target.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
>
|
||||
Profiles
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
{playlist && (
|
||||
<M3UProfiles
|
||||
playlist={playlist}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3U;
|
||||
158
vite/src/components/forms/M3UProfile.jsx
Normal file
158
vite/src/components/forms/M3UProfile.jsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Flex,
|
||||
Modal,
|
||||
TextInput,
|
||||
Button,
|
||||
Title,
|
||||
Text,
|
||||
Paper,
|
||||
} from '@mantine/core';
|
||||
|
||||
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
||||
const [searchPattern, setSearchPattern] = useState('');
|
||||
const [replacePattern, setReplacePattern] = useState('');
|
||||
|
||||
let regex;
|
||||
try {
|
||||
regex = new RegExp(searchPattern, 'g');
|
||||
} catch (e) {
|
||||
regex = null;
|
||||
}
|
||||
|
||||
const highlightedUrl = regex
|
||||
? m3u.server_url.replace(regex, (match) => `<mark>${match}</mark>`)
|
||||
: m3u.server_url;
|
||||
|
||||
const resultUrl =
|
||||
regex && replacePattern
|
||||
? m3u.server_url.replace(regex, replacePattern)
|
||||
: m3u.server_url;
|
||||
|
||||
const onSearchPatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setSearchPattern(e.target.value);
|
||||
};
|
||||
|
||||
const onReplacePatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setReplacePattern(e.target.value);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
max_streams: 0,
|
||||
search_pattern: '',
|
||||
replace_pattern: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
search_pattern: Yup.string().required('Search pattern is required'),
|
||||
replace_pattern: Yup.string().required('Replace pattern is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
console.log('submiting');
|
||||
if (profile?.id) {
|
||||
await API.updateM3UProfile(m3u.id, {
|
||||
id: profile.id,
|
||||
...values,
|
||||
});
|
||||
} else {
|
||||
await API.addM3UProfile(m3u.id, values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setSearchPattern(profile.search_pattern);
|
||||
setReplacePattern(profile.replace_pattern);
|
||||
formik.setValues({
|
||||
name: profile.name,
|
||||
max_streams: profile.max_streams,
|
||||
search_pattern: profile.search_pattern,
|
||||
replace_pattern: profile.replace_pattern,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="M3U Profile">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.name ? formik.touched.name : ''}
|
||||
/>
|
||||
<TextInput
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams"
|
||||
value={formik.values.max_streams}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
|
||||
/>
|
||||
<TextInput
|
||||
id="search_pattern"
|
||||
name="search_pattern"
|
||||
label="Search Pattern (Regex)"
|
||||
value={searchPattern}
|
||||
onChange={onSearchPatternUpdate}
|
||||
error={
|
||||
formik.errors.search_pattern ? formik.touched.search_pattern : ''
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
id="replace_pattern"
|
||||
name="replace_pattern"
|
||||
label="Replace Pattern"
|
||||
value={replacePattern}
|
||||
onChange={onReplacePatternUpdate}
|
||||
error={
|
||||
formik.errors.replace_pattern ? formik.touched.replace_pattern : ''
|
||||
}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
<Paper shadow="sm" p="md" radius="md" withBorder>
|
||||
<Text>Search</Text>
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
|
||||
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Text>Replace</Text>
|
||||
<Text>{resultUrl}</Text>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegexFormAndView;
|
||||
107
vite/src/components/forms/M3UProfiles.jsx
Normal file
107
vite/src/components/forms/M3UProfiles.jsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import API from '../../api';
|
||||
import M3UProfile from './M3UProfile';
|
||||
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import {
|
||||
Card,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Modal,
|
||||
Button,
|
||||
Box,
|
||||
ActionIcon,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
|
||||
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
|
||||
const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]);
|
||||
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const editProfile = (profile = null) => {
|
||||
if (profile) {
|
||||
setProfile(profile);
|
||||
}
|
||||
|
||||
setProfileEditorOpen(true);
|
||||
};
|
||||
|
||||
const deleteProfile = async (id) => {
|
||||
await API.deleteM3UProfile(playlist.id, id);
|
||||
};
|
||||
|
||||
const toggleActive = async (values) => {
|
||||
await API.updateM3UProfile(playlist.id, {
|
||||
...values,
|
||||
is_active: !values.is_active,
|
||||
});
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setProfile(null);
|
||||
setProfileEditorOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen || !profiles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={isOpen} onClose={onClose} title="Profiles">
|
||||
{profiles
|
||||
.filter((playlist) => playlist.is_default == false)
|
||||
.map((item) => (
|
||||
<Card
|
||||
// key={item.id}
|
||||
// sx={{
|
||||
// display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// marginBottom: 2,
|
||||
// }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text>Max Streams: {item.max_streams}</Text>
|
||||
<Checkbox
|
||||
label="Is Active"
|
||||
checked={item.is_active}
|
||||
onChange={() => toggleActive(item)}
|
||||
color="primary"
|
||||
/>
|
||||
<ActionIcon onClick={() => editProfile(item)} color="yellow.5">
|
||||
<EditIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
onClick={() => deleteProfile(item.id)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={editProfile}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Flex>
|
||||
</Modal>
|
||||
|
||||
<M3UProfile
|
||||
m3u={playlist}
|
||||
profile={profile}
|
||||
isOpen={profileEditorOpen}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UProfiles;
|
||||
103
vite/src/components/forms/Stream.jsx
Normal file
103
vite/src/components/forms/Stream.jsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { TextInput, Select, Button } from '@mantine/core';
|
||||
|
||||
const Stream = ({ stream = null, isOpen, onClose }) => {
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
url: '',
|
||||
stream_profile_id: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string().required('URL is required').min(0),
|
||||
// stream_profile_id: Yup.string().required('Stream profile is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (stream?.id) {
|
||||
await API.updateStream({ id: stream.id, ...values });
|
||||
} else {
|
||||
await API.addStream(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
formik.setValues({
|
||||
name: stream.name,
|
||||
url: stream.url,
|
||||
stream_profile_id: stream.stream_profile_id,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Stream">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Stream Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="Stream URL"
|
||||
value={formik.values.url}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.url && Boolean(formik.errors.url)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="stream_profile_id"
|
||||
name="stream_profile_id"
|
||||
label="Stream Profile (optional)"
|
||||
value={formik.values.stream_profile_id}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.errors.stream_profile_id
|
||||
? formik.touched.stream_profile_id
|
||||
: ''
|
||||
}
|
||||
data={streamProfiles.map((profile) => ({
|
||||
label: profile.profile_name,
|
||||
value: `${profile.id}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stream;
|
||||
111
vite/src/components/forms/StreamProfile.jsx
Normal file
111
vite/src/components/forms/StreamProfile.jsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import { Modal, TextInput, Select, Button } from '@mantine/core';
|
||||
|
||||
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
profile_name: '',
|
||||
command: '',
|
||||
parameters: '',
|
||||
is_active: true,
|
||||
user_agent: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
profile_name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string().required('Parameters are is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (profile?.id) {
|
||||
await API.updateStreamProfile({ id: profile.id, ...values });
|
||||
} else {
|
||||
await API.addStreamProfile(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
formik.setValues({
|
||||
profile_name: profile.profile_name,
|
||||
command: profile.command,
|
||||
parameters: profile.parameters,
|
||||
is_active: profile.is_active,
|
||||
user_agent: profile.user_agent,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={onClose} title="Stream Profile">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="profile_name"
|
||||
name="profile_name"
|
||||
label="Name"
|
||||
value={formik.values.profile_name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.profile_name}
|
||||
/>
|
||||
<TextInput
|
||||
id="command"
|
||||
name="command"
|
||||
label="Command"
|
||||
value={formik.values.command}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.command}
|
||||
/>
|
||||
<TextInput
|
||||
id="parameters"
|
||||
name="parameters"
|
||||
label="Parameters"
|
||||
value={formik.values.parameters}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.parameters}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.user_agent}
|
||||
data={userAgents.map((ua) => ({
|
||||
label: ua.user_agent_name,
|
||||
value: `${ua.id}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamProfile;
|
||||
128
vite/src/components/forms/SuperuserForm.jsx
Normal file
128
vite/src/components/forms/SuperuserForm.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// frontend/src/components/forms/SuperuserForm.js
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid2,
|
||||
TextField,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
|
||||
function SuperuserForm({ onSuccess }) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await axios.post('/api/accounts/initialize-superuser/', {
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
if (res.data.superuser_exists) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
let msg = 'Failed to create superuser.';
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
msg += ` ${err.response.data.error}`;
|
||||
}
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Paper elevation={3} sx={{ padding: 3, width: '100%', maxWidth: 400 }}>
|
||||
<Typography variant="h5" align="center" gutterBottom>
|
||||
Create your Super User Account
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography variant="body2" color="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Grid2
|
||||
container
|
||||
spacing={2}
|
||||
justifyContent="center"
|
||||
direction="column"
|
||||
>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Username"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Password"
|
||||
variant="standard"
|
||||
type="password"
|
||||
fullWidth
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Email (optional)"
|
||||
variant="standard"
|
||||
type="email"
|
||||
fullWidth
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
>
|
||||
Create Superuser
|
||||
</Button>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuperuserForm;
|
||||
119
vite/src/components/forms/UserAgent.jsx
Normal file
119
vite/src/components/forms/UserAgent.jsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
|
||||
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
user_agent_name: '',
|
||||
user_agent: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
user_agent_name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (userAgent?.id) {
|
||||
await API.updateUserAgent({ id: userAgent.id, ...values });
|
||||
} else {
|
||||
await API.addUserAgent(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userAgent) {
|
||||
formik.setValues({
|
||||
user_agent_name: userAgent.user_agent_name,
|
||||
user_agent: userAgent.user_agent,
|
||||
description: userAgent.description,
|
||||
is_active: userAgent.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [userAgent]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="User-Agent">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="user_agent_name"
|
||||
name="user_agent_name"
|
||||
label="Name"
|
||||
value={formik.values.user_agent_name}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.touched.user_agent_name &&
|
||||
Boolean(formik.errors.user_agent_name)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.user_agent && Boolean(formik.errors.user_agent)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.touched.description && Boolean(formik.errors.description)
|
||||
}
|
||||
/>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Checkbox
|
||||
name="is_active"
|
||||
label="Is Active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
size="small"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgent;
|
||||
1005
vite/src/components/tables/ChannelsTable.jsx
Normal file
1005
vite/src/components/tables/ChannelsTable.jsx
Normal file
File diff suppressed because it is too large
Load diff
168
vite/src/components/tables/EPGsTable.jsx
Normal file
168
vite/src/components/tables/EPGsTable.jsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import EPGForm from '../forms/EPG';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import { ActionIcon, Text, Tooltip, Box } from '@mantine/core';
|
||||
import useAlertStore from '../../store/alerts';
|
||||
|
||||
const EPGsTable = () => {
|
||||
const [epg, setEPG] = useState(null);
|
||||
const [epgModalOpen, setEPGModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
|
||||
const { showAlert } = useAlertStore();
|
||||
|
||||
const epgs = useEPGsStore((state) => state.epgs);
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Source Type',
|
||||
accessorKey: 'source_type',
|
||||
},
|
||||
{
|
||||
header: 'URL / API Key',
|
||||
accessorKey: 'max_streams',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editEPG = async (epg = null) => {
|
||||
setEPG(epg);
|
||||
setEPGModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteEPG = async (id) => {
|
||||
setIsLoading(true);
|
||||
await API.deleteEPG(id);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const refreshEPG = async (id) => {
|
||||
await API.refreshEPG(id);
|
||||
showAlert('EPG refresh initiated');
|
||||
};
|
||||
|
||||
const closeEPGForm = () => {
|
||||
setEPG(null);
|
||||
setEPGModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: epgs,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
color="yellow.5" // Red color for delete actions
|
||||
onClick={() => editEPG(row.original)}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
color="red.5" // Red color for delete actions
|
||||
onClick={() => deleteEPG(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
// color="blue.5" // Red color for delete actions
|
||||
onClick={() => refreshEPG(row.original.id)}
|
||||
>
|
||||
<RefreshIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</>
|
||||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(40vh - 0px)',
|
||||
},
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<>
|
||||
<Text>EPGs</Text>
|
||||
<Tooltip label="Add New EPG">
|
||||
<ActionIcon
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editEPG()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
<MantineReactTable table={table} />
|
||||
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPGsTable;
|
||||
242
vite/src/components/tables/M3UsTable.jsx
Normal file
242
vite/src/components/tables/M3UsTable.jsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table';
|
||||
import {
|
||||
Box,
|
||||
Stack,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
SwapVert as SwapVertIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import M3UForm from '../forms/M3U';
|
||||
import { TableHelper } from '../../helpers';
|
||||
|
||||
const Example = () => {
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
|
||||
const playlists = usePlaylistsStore((state) => state.playlists);
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'URL / File',
|
||||
accessorKey: 'server_url',
|
||||
},
|
||||
{
|
||||
header: 'Max Streams',
|
||||
accessorKey: 'max_streams',
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
muiTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{cell.getValue() ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<CloseIcon color="error" />
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Box>
|
||||
<Select
|
||||
size="small"
|
||||
variant="standard"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="true">Active</MenuItem>
|
||||
<MenuItem value="false">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
),
|
||||
filterFn: (row, _columnId, activeFilterValue) => {
|
||||
if (!activeFilterValue) return true; // Show all if no filter
|
||||
return String(row.getValue('is_active')) === activeFilterValue;
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editPlaylist = async (playlist = null) => {
|
||||
if (playlist) {
|
||||
setPlaylist(playlist);
|
||||
}
|
||||
setPlaylistModalOpen(true);
|
||||
};
|
||||
|
||||
const refreshPlaylist = async (id) => {
|
||||
await API.refreshPlaylist(id);
|
||||
};
|
||||
|
||||
const deletePlaylist = async (id) => {
|
||||
await API.deletePlaylist(id);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setPlaylistModalOpen(false);
|
||||
setPlaylist(null);
|
||||
};
|
||||
|
||||
const deletePlaylists = async (ids) => {
|
||||
const selected = table
|
||||
.getRowModel()
|
||||
.rows.filter((row) => row.getIsSelected());
|
||||
// await API.deleteStreams(selected.map(stream => stream.original.id))
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: playlists,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
// enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editPlaylist(row.original);
|
||||
}}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deletePlaylist(row.original.id)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => refreshPlaylist(row.original.id)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<RefreshIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(43vh - 0px)',
|
||||
pr: 1,
|
||||
pl: 1,
|
||||
},
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography>M3U Accounts</Typography>
|
||||
<Tooltip title="Add New M3U Account">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editPlaylist()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
<MaterialReactTable table={table} />
|
||||
<M3UForm
|
||||
playlist={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Example;
|
||||
202
vite/src/components/tables/StreamProfilesTable.jsx
Normal file
202
vite/src/components/tables/StreamProfilesTable.jsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import StreamProfileForm from '../forms/StreamProfile';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import useAlertStore from '../../store/alerts';
|
||||
import { Box, ActionIcon, Tooltip, Text } from '@mantine/core';
|
||||
|
||||
const StreamProfiles = () => {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
const { settings } = useSettingsStore();
|
||||
const { showAlert } = useAlertStore();
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'profile_name',
|
||||
},
|
||||
{
|
||||
header: 'Command',
|
||||
accessorKey: 'command',
|
||||
},
|
||||
{
|
||||
header: 'Parameters',
|
||||
accessorKey: 'parameters',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
muiTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{cell.getValue() ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<CloseIcon color="error" />
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Box>
|
||||
<Select
|
||||
size="small"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
data={['All', 'Active', 'Inactive']}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
filterFn: (row, _columnId, filterValue) => {
|
||||
if (filterValue == 'all') return true;
|
||||
return String(row.getValue('is_active')) === filterValue;
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editStreamProfile = async (profile = null) => {
|
||||
setProfile(profile);
|
||||
setProfileModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteStreamProfile = async (id) => {
|
||||
if (id == settings['default-stream-profile'].value) {
|
||||
showAlert('Cannot delete default stream-profile', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await API.deleteStreamProfile(id);
|
||||
};
|
||||
|
||||
const closeStreamProfileForm = () => {
|
||||
setProfile(null);
|
||||
setProfileModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: streamProfiles,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="yellow.5"
|
||||
onClick={() => editStreamProfile(row.original)}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="small"
|
||||
color="red.5"
|
||||
onClick={() => deleteStreamProfile(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</>
|
||||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(100vh - 90px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<>
|
||||
<Text>Stream Profiles</Text>
|
||||
<Tooltip label="Add New Stream Profile">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="green.5"
|
||||
onClick={() => editStreamProfile()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
<MantineReactTable table={table} />
|
||||
|
||||
<StreamProfileForm
|
||||
profile={profile}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={closeStreamProfileForm}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamProfiles;
|
||||
608
vite/src/components/tables/StreamsTable.jsx
Normal file
608
vite/src/components/tables/StreamsTable.jsx
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
MoreVert as MoreVertIcon,
|
||||
PlaylistAdd as PlaylistAddIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import StreamForm from '../forms/Stream';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { useDebounce } from '../../utils';
|
||||
import { SquarePlus, ListPlus } from 'lucide-react';
|
||||
import {
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Select,
|
||||
Tooltip,
|
||||
Menu,
|
||||
Flex,
|
||||
Box,
|
||||
Text,
|
||||
Paper,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconDeviceDesktopSearch,
|
||||
IconSelector,
|
||||
IconSortAscendingNumbers,
|
||||
IconSquareMinus,
|
||||
IconSquarePlus,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const StreamsTable = ({}) => {
|
||||
/**
|
||||
* useState
|
||||
*/
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [stream, setStream] = useState(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [m3uOptions, setM3uOptions] = useState([]);
|
||||
const [actionsOpenRow, setActionsOpenRow] = useState(null);
|
||||
|
||||
const [data, setData] = useState([]); // Holds fetched data
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
|
||||
const [unselectedStreamIds, setUnselectedStreamIds] = useState([]);
|
||||
// const [allRowsSelected, setAllRowsSelected] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
name: '',
|
||||
group_name: '',
|
||||
m3u_account: '',
|
||||
});
|
||||
const debouncedFilters = useDebounce(filters, 500);
|
||||
|
||||
/**
|
||||
* Stores
|
||||
*/
|
||||
const { playlists } = usePlaylistsStore();
|
||||
const { channelsPageSelection } = useChannelsStore();
|
||||
const channelSelectionStreams = useChannelsStore(
|
||||
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
|
||||
);
|
||||
|
||||
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
|
||||
|
||||
// Access the row virtualizer instance (optional)
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
/**
|
||||
* useMemo
|
||||
*/
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
mantineTableHeadCellProps: {
|
||||
style: { textAlign: 'center' }, // Center-align the header
|
||||
},
|
||||
Header: ({ column }) => (
|
||||
<TextInput
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
value={filters.name || ''}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={handleFilterChange}
|
||||
size="xs"
|
||||
margin="none"
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorKey: 'group_name',
|
||||
size: 100,
|
||||
Header: ({ column }) => (
|
||||
<Select
|
||||
placeholder="Group"
|
||||
searchable
|
||||
size="xs"
|
||||
nothingFound="No options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={handleGroupChange}
|
||||
data={groupOptions}
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
size: 75,
|
||||
accessorFn: (row) =>
|
||||
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
Header: ({ column }) => (
|
||||
<Select
|
||||
placeholder="M3U"
|
||||
searchable
|
||||
size="xs"
|
||||
nothingFound="No options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={handleM3UChange}
|
||||
data={playlists.map((playlist) => ({
|
||||
label: playlist.name,
|
||||
value: `${playlist.id}`,
|
||||
}))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[playlists, groupOptions, filters]
|
||||
);
|
||||
|
||||
/**
|
||||
* Functions
|
||||
*/
|
||||
const handleFilterChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleGroupChange = (value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
group_name: value ? value.value : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const handleM3UChange = (value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
m3u_account: value ? value.value : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
||||
// Apply sorting
|
||||
if (sorting.length > 0) {
|
||||
const sortField = sorting[0].id;
|
||||
const sortDirection = sorting[0].desc ? '-' : '';
|
||||
params.append('ordering', `${sortDirection}${sortField}`);
|
||||
}
|
||||
|
||||
// Apply debounced filters
|
||||
Object.entries(debouncedFilters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await API.queryStreams(params);
|
||||
setData(result.results);
|
||||
setRowCount(result.count);
|
||||
|
||||
const newSelection = {};
|
||||
result.results.forEach((item, index) => {
|
||||
if (selectedStreamIds.includes(item.id)) {
|
||||
newSelection[index] = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Only update rowSelection if it's different
|
||||
if (JSON.stringify(newSelection) !== JSON.stringify(rowSelection)) {
|
||||
setRowSelection(newSelection);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
|
||||
const groups = await API.getStreamGroups();
|
||||
setGroupOptions(groups);
|
||||
|
||||
setIsLoading(false);
|
||||
}, [pagination, sorting, debouncedFilters]);
|
||||
|
||||
// Fallback: Individual creation (optional)
|
||||
const createChannelFromStream = async (stream) => {
|
||||
await API.createChannelFromStream({
|
||||
channel_name: stream.name,
|
||||
channel_number: null,
|
||||
stream_id: stream.id,
|
||||
});
|
||||
};
|
||||
|
||||
// Bulk creation: create channels from selected streams in one API call
|
||||
const createChannelsFromStreams = async () => {
|
||||
setIsLoading(true);
|
||||
await API.createChannelsFromStreams(
|
||||
selectedStreamIds.map((stream_id) => ({
|
||||
stream_id,
|
||||
}))
|
||||
);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const editStream = async (stream = null) => {
|
||||
setStream(stream);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteStream = async (id) => {
|
||||
await API.deleteStream(id);
|
||||
};
|
||||
|
||||
const deleteStreams = async () => {
|
||||
setIsLoading(true);
|
||||
await API.deleteStreams(selectedStreamIds);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const closeStreamForm = () => {
|
||||
setStream(null);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const addStreamsToChannel = async () => {
|
||||
const { streams, ...channel } = { ...channelsPageSelection[0] };
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
stream_ids: [
|
||||
...new Set(
|
||||
channelSelectionStreams
|
||||
.map((stream) => stream.id)
|
||||
.concat(selectedStreamIds)
|
||||
),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const addStreamToChannel = async (streamId) => {
|
||||
const { streams, ...channel } = { ...channelsPageSelection[0] };
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
stream_ids: [
|
||||
...new Set(
|
||||
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
|
||||
),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoreActionsClick = (event, rowId) => {
|
||||
setMoreActionsAnchorEl(event.currentTarget);
|
||||
setActionsOpenRow(rowId);
|
||||
};
|
||||
|
||||
const handleMoreActionsClose = () => {
|
||||
setMoreActionsAnchorEl(null);
|
||||
setActionsOpenRow(null);
|
||||
};
|
||||
|
||||
const onRowSelectionChange = (updater) => {
|
||||
setRowSelection((prevRowSelection) => {
|
||||
const newRowSelection =
|
||||
typeof updater === 'function' ? updater(prevRowSelection) : updater;
|
||||
|
||||
const updatedSelected = new Set([...selectedStreamIds]);
|
||||
table.getRowModel().rows.map((row) => {
|
||||
if (newRowSelection[row.id] === undefined || !newRowSelection[row.id]) {
|
||||
updatedSelected.delete(row.original.id);
|
||||
} else {
|
||||
updatedSelected.add(row.original.id);
|
||||
}
|
||||
});
|
||||
setSelectedStreamIds([...updatedSelected]);
|
||||
|
||||
return newRowSelection;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelectAllChange = async (e) => {
|
||||
const selectAll = e.target.checked;
|
||||
if (selectAll) {
|
||||
// Get all stream IDs for current view
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Apply debounced filters
|
||||
Object.entries(debouncedFilters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
const ids = await API.getAllStreamIds(params);
|
||||
setSelectedStreamIds(ids);
|
||||
} else {
|
||||
setSelectedStreamIds([]);
|
||||
}
|
||||
|
||||
const newSelection = {};
|
||||
table.getRowModel().rows.forEach((item, index) => {
|
||||
newSelection[index] = selectAll;
|
||||
});
|
||||
setRowSelection(newSelection);
|
||||
};
|
||||
|
||||
const onPageSizeChange = (pageSize) => {
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
const onPageIndexChange = (pageIndex) => {
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const onPaginationChange = (updater) => {
|
||||
const newPagination = updater(pagination);
|
||||
if (JSON.stringify(newPagination) === JSON.stringify(pagination)) {
|
||||
// Prevent infinite re-render when there are no results
|
||||
return;
|
||||
}
|
||||
|
||||
setPagination(updater);
|
||||
};
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data,
|
||||
enablePagination: true,
|
||||
manualPagination: true,
|
||||
enableTopToolbar: false,
|
||||
enableRowVirtualization: true,
|
||||
rowVirtualizerInstanceRef,
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
enableBottomToolbar: true,
|
||||
enableStickyHeader: true,
|
||||
onPaginationChange: onPaginationChange,
|
||||
rowCount: rowCount,
|
||||
enableRowSelection: true,
|
||||
muiPaginationProps: {
|
||||
size: 'small',
|
||||
rowsPerPageOptions: [25, 50, 100, 250, 500, 1000, 10000],
|
||||
labelRowsPerPage: 'Rows per page',
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: onRowSelectionChange,
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
state: {
|
||||
isLoading: isLoading,
|
||||
sorting,
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: 'first',
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<Tooltip label="Add to Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="blue.5"
|
||||
variant="transparent"
|
||||
onClick={() => addStreamToChannel(row.original.id)}
|
||||
disabled={
|
||||
channelsPageSelection.length != 1 ||
|
||||
(channelSelectionStreams &&
|
||||
channelSelectionStreams
|
||||
.map((stream) => stream.id)
|
||||
.includes(row.original.id))
|
||||
}
|
||||
>
|
||||
<ListPlus size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Create New Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="green.5"
|
||||
variant="transparent"
|
||||
onClick={() => createChannelFromStream(row.original)}
|
||||
>
|
||||
<SquarePlus size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
onClick={(event) =>
|
||||
handleMoreActionsClick(event, row.original.id)
|
||||
}
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => editStream(row.original.id)}
|
||||
disabled={row.original.m3u_account ? true : false}
|
||||
>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => deleteStream(row.original.id)}>
|
||||
Delete Stream
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(100vh - 165px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
size: 68,
|
||||
},
|
||||
'mrt-row-select': {
|
||||
size: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* useEffects
|
||||
*/
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to the top of the table when sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex style={{ display: 'flex', alignItems: 'center', pb: 1 }} gap={15}>
|
||||
<Text
|
||||
w={88}
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Streams
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Paper
|
||||
style={
|
||||
{
|
||||
// bgcolor: theme.palette.background.paper,
|
||||
// borderRadius: 2,
|
||||
// overflow: 'hidden',
|
||||
// height: 'calc(100vh - 75px)',
|
||||
// display: 'flex',
|
||||
// flexDirection: 'column',
|
||||
}
|
||||
}
|
||||
>
|
||||
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Tooltip label="Remove Channels">
|
||||
<Button
|
||||
leftSection={<IconSquareMinus size={14} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={deleteStreams}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Auto-Match EPG">
|
||||
<Button
|
||||
leftSection={<IconDeviceDesktopSearch size={14} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={createChannelsFromStreams}
|
||||
p={5}
|
||||
>
|
||||
Create Channels
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Assign">
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={14} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={editStream}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add Stream
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<MantineReactTable table={table} />
|
||||
<StreamForm
|
||||
stream={stream}
|
||||
isOpen={modalOpen}
|
||||
onClose={closeStreamForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamsTable;
|
||||
219
vite/src/components/tables/UserAgentsTable.jsx
Normal file
219
vite/src/components/tables/UserAgentsTable.jsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import UserAgentForm from '../forms/UserAgent';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import useAlertStore from '../../store/alerts';
|
||||
import { ActionIcon, Center, Flex, Select, Tooltip, Text } from '@mantine/core';
|
||||
|
||||
const UserAgentsTable = () => {
|
||||
const [userAgent, setUserAgent] = useState(null);
|
||||
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
const { settings } = useSettingsStore();
|
||||
const { showAlert } = useAlertStore();
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'user_agent_name',
|
||||
},
|
||||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
},
|
||||
{
|
||||
header: 'Desecription',
|
||||
accessorKey: 'description',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
muiTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Center>
|
||||
{cell.getValue() ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<CloseIcon color="error" />
|
||||
)}
|
||||
</Center>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Select
|
||||
size="small"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
data={[
|
||||
{
|
||||
value: 'all',
|
||||
label: 'All',
|
||||
},
|
||||
{
|
||||
value: 'active',
|
||||
label: 'Active',
|
||||
},
|
||||
{
|
||||
value: 'inactive',
|
||||
label: 'Inactive',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
filterFn: (row, _columnId, activeFilterValue) => {
|
||||
if (activeFilterValue == 'all') return true; // Show all if no filter
|
||||
return String(row.getValue('is_active')) === activeFilterValue;
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editUserAgent = async (userAgent = null) => {
|
||||
setUserAgent(userAgent);
|
||||
setUserAgentModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteUserAgent = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
if (ids.includes(settings['default-user-agent'].value)) {
|
||||
showAlert('Cannot delete default user-agent', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await API.deleteUserAgents(ids);
|
||||
} else {
|
||||
if (ids == settings['default-user-agent'].value) {
|
||||
showAlert('Cannot delete default user-agent', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await API.deleteUserAgent(ids);
|
||||
}
|
||||
};
|
||||
|
||||
const closeUserAgentForm = () => {
|
||||
setUserAgent(null);
|
||||
setUserAgentModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: userAgents,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="small" // Makes the button smaller
|
||||
color="yellow.5" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editUserAgent(row.original);
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteUserAgent(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(42vh + 5px)',
|
||||
},
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<Flex directino="row">
|
||||
<Text>User-Agents</Text>
|
||||
<Tooltip label="Add New User Agent">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="small" // Makes the button smaller
|
||||
color="green.5" // Red color for delete actions
|
||||
onClick={() => editUserAgent()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MantineReactTable table={table} />
|
||||
<UserAgentForm
|
||||
userAgent={userAgent}
|
||||
isOpen={userAgentModalOpen}
|
||||
onClose={closeUserAgentForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgentsTable;
|
||||
3
vite/src/helpers/index.jsx
Normal file
3
vite/src/helpers/index.jsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import table from "./table";
|
||||
|
||||
export const TableHelper = table;
|
||||
61
vite/src/helpers/table.jsx
Normal file
61
vite/src/helpers/table.jsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// frontend/src/helpers/table.js
|
||||
|
||||
export default {
|
||||
defaultProperties: {
|
||||
enableBottomToolbar: false,
|
||||
enableDensityToggle: false,
|
||||
enableFullScreenToggle: false,
|
||||
positionToolbarAlertBanner: 'none',
|
||||
// columnFilterDisplayMode: 'popover',
|
||||
enableRowNumbers: false,
|
||||
positionActionsColumn: 'last',
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
enableGlobalFilter: false,
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
mantineSelectAllCheckboxProps: {
|
||||
size: 'xs',
|
||||
},
|
||||
mantineSelectCheckboxProps: {
|
||||
size: 'xs',
|
||||
},
|
||||
mantineTableBodyCellProps: {
|
||||
style: {
|
||||
// py: 0,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
borderColor: '#444',
|
||||
color: '#E0E0E0',
|
||||
fontSize: '0.85rem',
|
||||
},
|
||||
},
|
||||
mantineTableHeadCellProps: {
|
||||
style: {
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
color: '#CFCFCF',
|
||||
backgroundColor: '#383A3F',
|
||||
borderColor: '#444',
|
||||
// fontWeight: 600,
|
||||
// fontSize: '0.8rem',
|
||||
},
|
||||
},
|
||||
mantineTableBodyProps: {
|
||||
style: {
|
||||
// Subtle row striping
|
||||
'& tr:nth-of-type(odd)': {
|
||||
backgroundColor: '#2F3034',
|
||||
},
|
||||
'& tr:nth-of-type(even)': {
|
||||
backgroundColor: '#333539',
|
||||
},
|
||||
// Row hover effect
|
||||
'& tr:hover td': {
|
||||
backgroundColor: '#3B3D41',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
23
vite/src/images/dispatcharr.svg
Normal file
23
vite/src/images/dispatcharr.svg
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<svg width="432" height="100" viewBox="0 0 432 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_12_1828)">
|
||||
<path d="M17.1193 17.1819L17.8539 86.9621C11.9776 87.6967 7.57051 84.7585 7.57051 78.8823L6.83594 20.8545C6.83594 2.49131 23.7301 -1.91582 34.0135 5.42939L86.165 35.5451C93.5104 40.6868 94.9794 50.2356 91.3067 56.8464C90.5723 51.7047 88.3687 48.7666 83.9614 45.8285L25.1991 12.7747C20.792 9.83662 17.1193 10.5711 17.1193 17.1819Z" fill="#3E464E"/>
|
||||
<path d="M12.7119 90.6348C17.1191 92.1038 21.5263 91.3693 25.1989 89.1657L85.4303 53.9083C89.1029 59.05 88.3685 64.1917 83.9612 67.1298L33.2787 96.511C25.9334 100.184 16.3846 96.511 12.7119 90.6348Z" fill="#14917E"/>
|
||||
<path d="M30.3408 70.0679L66.3327 49.5011L31.0754 29.6688L30.3408 70.0679Z" fill="#14917E"/>
|
||||
</g>
|
||||
<path d="M137.25 69.5666H124V28.6387H137.669C141.679 28.6387 145.123 29.4581 148.001 31.0968C150.879 32.7222 153.084 35.0603 154.616 38.1113C156.161 41.1489 156.934 44.7927 156.934 49.0427C156.934 53.306 156.155 56.9698 154.596 60.0341C153.051 63.0984 150.812 65.4565 147.881 67.1085C144.95 68.7473 141.406 69.5666 137.25 69.5666ZM130.175 64.1708H136.91C140.027 64.1708 142.619 63.5846 144.684 62.4122C146.749 61.2265 148.294 59.5145 149.32 57.2763C150.346 55.0247 150.859 52.2802 150.859 49.0427C150.859 45.8319 150.346 43.1074 149.32 40.8691C148.308 38.6309 146.795 36.9322 144.784 35.7731C142.772 34.614 140.274 34.0345 137.29 34.0345H130.175V64.1708Z" fill="white"/>
|
||||
<path d="M164.134 69.5666V38.8707H170.109V69.5666H164.134ZM167.151 34.1344C166.112 34.1344 165.219 33.788 164.473 33.0952C163.741 32.3891 163.374 31.5498 163.374 30.5772C163.374 29.5913 163.741 28.752 164.473 28.0592C165.219 27.3531 166.112 27 167.151 27C168.19 27 169.076 27.3531 169.809 28.0592C170.555 28.752 170.928 29.5913 170.928 30.5772C170.928 31.5498 170.555 32.3891 169.809 33.0952C169.076 33.788 168.19 34.1344 167.151 34.1344Z" fill="white"/>
|
||||
<path d="M201.149 46.3648L195.734 47.3241C195.507 46.6313 195.148 45.9718 194.655 45.3456C194.175 44.7194 193.522 44.2065 192.696 43.8068C191.87 43.4071 190.838 43.2073 189.599 43.2073C187.907 43.2073 186.494 43.587 185.362 44.3464C184.229 45.0925 183.663 46.0584 183.663 47.2441C183.663 48.27 184.043 49.096 184.802 49.7222C185.562 50.3484 186.787 50.8613 188.479 51.261L193.356 52.3801C196.18 53.0329 198.285 54.0388 199.671 55.3977C201.056 56.7567 201.749 58.5219 201.749 60.6936C201.749 62.5321 201.216 64.1708 200.15 65.6097C199.098 67.0353 197.626 68.1544 195.734 68.9671C193.855 69.7798 191.677 70.1861 189.199 70.1861C185.762 70.1861 182.957 69.4534 180.785 67.9879C178.614 66.509 177.282 64.4107 176.789 61.6928L182.564 60.8135C182.924 62.319 183.663 63.4581 184.782 64.2308C185.901 64.9902 187.36 65.3699 189.159 65.3699C191.117 65.3699 192.683 64.9636 193.855 64.1509C195.028 63.3248 195.614 62.319 195.614 61.1332C195.614 60.174 195.254 59.368 194.535 58.7151C193.829 58.0623 192.743 57.5694 191.277 57.2363L186.081 56.0972C183.217 55.4444 181.099 54.4052 179.726 52.9796C178.367 51.5541 177.688 49.7488 177.688 47.5639C177.688 45.752 178.194 44.1665 179.207 42.8076C180.219 41.4487 181.618 40.3895 183.403 39.6301C185.189 38.8574 187.234 38.471 189.539 38.471C192.856 38.471 195.467 39.1904 197.372 40.6293C199.278 42.0549 200.537 43.9667 201.149 46.3648Z" fill="white"/>
|
||||
<path d="M208.319 81.0776V38.8707H214.154V43.8468H214.654C215 43.2073 215.5 42.4679 216.153 41.6285C216.806 40.7892 217.712 40.0564 218.871 39.4303C220.03 38.7908 221.562 38.471 223.467 38.471C225.945 38.471 228.157 39.0972 230.102 40.3495C232.047 41.6019 233.572 43.4071 234.678 45.7653C235.797 48.1234 236.357 50.9612 236.357 54.2786C236.357 57.596 235.804 60.4404 234.698 62.8119C233.592 65.1701 232.074 66.9886 230.142 68.2676C228.21 69.5333 226.005 70.1661 223.527 70.1661C221.662 70.1661 220.136 69.8531 218.951 69.2269C217.778 68.6007 216.859 67.8679 216.193 67.0286C215.527 66.1893 215.014 65.4432 214.654 64.7904H214.294V81.0776H208.319ZM214.174 54.2187C214.174 56.377 214.487 58.2688 215.114 59.8942C215.74 61.5196 216.646 62.7919 217.831 63.7112C219.017 64.6172 220.469 65.0701 222.188 65.0701C223.973 65.0701 225.465 64.5972 226.664 63.6513C227.864 62.692 228.769 61.393 229.382 59.7543C230.009 58.1156 230.322 56.2704 230.322 54.2187C230.322 52.1936 230.015 50.375 229.402 48.7629C228.803 47.1509 227.897 45.8785 226.684 44.9459C225.485 44.0133 223.987 43.547 222.188 43.547C220.456 43.547 218.991 43.9933 217.791 44.886C216.606 45.7786 215.706 47.0243 215.094 48.623C214.481 50.2218 214.174 52.087 214.174 54.2187Z" fill="white"/>
|
||||
<path d="M251.985 70.2461C250.039 70.2461 248.281 69.8864 246.709 69.1669C245.137 68.4342 243.891 67.375 242.972 65.9894C242.066 64.6038 241.613 62.9052 241.613 60.8934C241.613 59.1614 241.946 57.7359 242.612 56.6168C243.278 55.4977 244.177 54.6117 245.31 53.9589C246.442 53.306 247.708 52.8131 249.107 52.48C250.506 52.1469 251.931 51.8938 253.384 51.7206C255.222 51.5074 256.714 51.3342 257.86 51.201C259.006 51.0545 259.838 50.8213 260.358 50.5016C260.878 50.1818 261.137 49.6622 261.137 48.9428V48.8029C261.137 47.0576 260.645 45.7053 259.659 44.7461C258.686 43.7868 257.234 43.3072 255.302 43.3072C253.29 43.3072 251.705 43.7535 250.546 44.6462C249.4 45.5255 248.607 46.5047 248.168 47.5839L242.552 46.3049C243.218 44.4397 244.191 42.9342 245.47 41.7884C246.762 40.6293 248.248 39.79 249.926 39.2704C251.605 38.7375 253.37 38.471 255.222 38.471C256.448 38.471 257.747 38.6176 259.119 38.9107C260.505 39.1904 261.797 39.71 262.996 40.4694C264.208 41.2288 265.201 42.3147 265.974 43.7269C266.746 45.1258 267.133 46.9444 267.133 49.1826V69.5666H261.297V65.3699H261.058C260.671 66.1426 260.092 66.902 259.319 67.6481C258.546 68.3942 257.554 69.0137 256.341 69.5067C255.129 69.9996 253.677 70.2461 251.985 70.2461ZM253.284 65.4498C254.936 65.4498 256.348 65.1234 257.52 64.4706C258.706 63.8178 259.605 62.9651 260.218 61.9126C260.844 60.8468 261.157 59.7077 261.157 58.4953V54.5384C260.944 54.7516 260.531 54.9514 259.918 55.1379C259.319 55.3111 258.633 55.4643 257.86 55.5976C257.087 55.7175 256.335 55.8307 255.602 55.9373C254.869 56.0306 254.256 56.1105 253.763 56.1771C252.604 56.3237 251.545 56.5701 250.586 56.9165C249.64 57.2629 248.88 57.7625 248.308 58.4154C247.748 59.0549 247.468 59.9075 247.468 60.9734C247.468 62.4522 248.014 63.5713 249.107 64.3307C250.199 65.0768 251.592 65.4498 253.284 65.4498Z" fill="white"/>
|
||||
<path d="M289.42 38.8707V43.6669H272.653V38.8707H289.42ZM277.15 31.5165H283.125V60.5537C283.125 61.7128 283.298 62.5854 283.645 63.1716C283.991 63.7445 284.438 64.1375 284.984 64.3507C285.543 64.5506 286.149 64.6505 286.802 64.6505C287.282 64.6505 287.702 64.6172 288.061 64.5506C288.421 64.4839 288.701 64.4306 288.901 64.3907L289.98 69.3268C289.633 69.46 289.14 69.5933 288.501 69.7265C287.861 69.873 287.062 69.953 286.103 69.9663C284.531 69.993 283.065 69.7132 281.706 69.127C280.347 68.5408 279.248 67.6348 278.409 66.4091C277.57 65.1834 277.15 63.6446 277.15 61.7927V31.5165Z" fill="white"/>
|
||||
<path d="M308.63 70.1861C305.659 70.1861 303.101 69.5133 300.956 68.1677C298.825 66.8088 297.186 64.9369 296.04 62.5521C294.894 60.1673 294.321 57.4361 294.321 54.3585C294.321 51.241 294.908 48.4898 296.08 46.105C297.252 43.7069 298.904 41.835 301.036 40.4894C303.168 39.1438 305.679 38.471 308.57 38.471C310.902 38.471 312.98 38.904 314.805 39.77C316.631 40.6226 318.103 41.8217 319.222 43.3672C320.354 44.9126 321.027 46.7179 321.24 48.7829H315.425C315.105 47.344 314.372 46.105 313.227 45.0658C312.094 44.0266 310.575 43.5071 308.67 43.5071C307.005 43.5071 305.546 43.9467 304.294 44.826C303.055 45.692 302.089 46.931 301.396 48.5431C300.703 50.1419 300.357 52.0337 300.357 54.2187C300.357 56.4569 300.696 58.3887 301.376 60.0141C302.055 61.6395 303.015 62.8985 304.254 63.7911C305.506 64.6838 306.978 65.1301 308.67 65.1301C309.803 65.1301 310.828 64.9236 311.748 64.5106C312.68 64.0843 313.46 63.4781 314.086 62.692C314.725 61.906 315.172 60.96 315.425 59.8542H321.24C321.027 61.8393 320.381 63.6113 319.302 65.1701C318.223 66.7288 316.777 67.9545 314.965 68.8472C313.167 69.7398 311.055 70.1861 308.63 70.1861Z" fill="white"/>
|
||||
<path d="M333.606 51.3409V69.5666H327.63V28.6387H333.526V43.8668H333.905C334.625 42.2147 335.724 40.9024 337.203 39.9299C338.682 38.9573 340.613 38.471 342.998 38.471C345.103 38.471 346.942 38.904 348.514 39.77C350.099 40.636 351.325 41.9283 352.191 43.6469C353.07 45.3523 353.51 47.4839 353.51 50.0419V69.5666H347.535V50.7614C347.535 48.5098 346.955 46.7645 345.796 45.5255C344.637 44.2731 343.025 43.6469 340.96 43.6469C339.548 43.6469 338.282 43.9467 337.163 44.5462C336.057 45.1458 335.184 46.0251 334.545 47.1842C333.919 48.3299 333.606 49.7155 333.606 51.3409Z" fill="white"/>
|
||||
<path d="M370.417 70.2461C368.472 70.2461 366.713 69.8864 365.141 69.1669C363.569 68.4342 362.323 67.375 361.404 65.9894C360.498 64.6038 360.045 62.9052 360.045 60.8934C360.045 59.1614 360.378 57.7359 361.044 56.6168C361.71 55.4977 362.61 54.6117 363.742 53.9589C364.874 53.306 366.14 52.8131 367.539 52.48C368.938 52.1469 370.363 51.8938 371.816 51.7206C373.654 51.5074 375.146 51.3342 376.292 51.201C377.438 51.0545 378.271 50.8213 378.79 50.5016C379.31 50.1818 379.57 49.6622 379.57 48.9428V48.8029C379.57 47.0576 379.077 45.7053 378.091 44.7461C377.118 43.7868 375.666 43.3072 373.734 43.3072C371.722 43.3072 370.137 43.7535 368.978 44.6462C367.832 45.5255 367.039 46.5047 366.6 47.5839L360.984 46.3049C361.65 44.4397 362.623 42.9342 363.902 41.7884C365.194 40.6293 366.68 39.79 368.358 39.2704C370.037 38.7375 371.802 38.471 373.654 38.471C374.88 38.471 376.179 38.6176 377.551 38.9107C378.937 39.1904 380.229 39.71 381.428 40.4694C382.64 41.2288 383.633 42.3147 384.406 43.7269C385.179 45.1258 385.565 46.9444 385.565 49.1826V69.5666H379.729V65.3699H379.49C379.103 66.1426 378.524 66.902 377.751 67.6481C376.978 68.3942 375.986 69.0137 374.773 69.5067C373.561 69.9996 372.109 70.2461 370.417 70.2461ZM371.716 65.4498C373.368 65.4498 374.78 65.1234 375.952 64.4706C377.138 63.8178 378.037 62.9651 378.65 61.9126C379.276 60.8468 379.59 59.7077 379.59 58.4953V54.5384C379.376 54.7516 378.963 54.9514 378.351 55.1379C377.751 55.3111 377.065 55.4643 376.292 55.5976C375.519 55.7175 374.767 55.8307 374.034 55.9373C373.301 56.0306 372.688 56.1105 372.195 56.1771C371.036 56.3237 369.977 56.5701 369.018 56.9165C368.072 57.2629 367.313 57.7625 366.74 58.4154C366.18 59.0549 365.9 59.9075 365.9 60.9734C365.9 62.4522 366.447 63.5713 367.539 64.3307C368.631 65.0768 370.024 65.4498 371.716 65.4498Z" fill="white"/>
|
||||
<path d="M393.524 69.5666V38.8707H399.299V43.7469H399.619C400.178 42.0948 401.164 40.7958 402.577 39.8499C404.002 38.8907 405.614 38.411 407.413 38.411C407.786 38.411 408.225 38.4244 408.732 38.451C409.251 38.4777 409.658 38.511 409.951 38.5509V44.2665C409.711 44.1998 409.285 44.1266 408.672 44.0466C408.059 43.9534 407.446 43.9067 406.833 43.9067C405.421 43.9067 404.162 44.2065 403.056 44.806C401.964 45.3922 401.098 46.2116 400.458 47.2641C399.819 48.3033 399.499 49.489 399.499 50.8213V69.5666H393.524Z" fill="white"/>
|
||||
<path d="M415.122 69.5666V38.8707H420.897V43.7469H421.217C421.776 42.0948 422.762 40.7958 424.175 39.8499C425.6 38.8907 427.212 38.411 429.011 38.411C429.384 38.411 429.824 38.4244 430.33 38.451C430.849 38.4777 431.256 38.511 431.549 38.5509V44.2665C431.309 44.1998 430.883 44.1266 430.27 44.0466C429.657 43.9534 429.044 43.9067 428.431 43.9067C427.019 43.9067 425.76 44.2065 424.654 44.806C423.562 45.3922 422.696 46.2116 422.056 47.2641C421.417 48.3033 421.097 49.489 421.097 50.8213V69.5666H415.122Z" fill="white"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_12_1828">
|
||||
<rect width="100" height="100" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
66
vite/src/images/ghost.svg
Normal file
66
vite/src/images/ghost.svg
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="961.698874pt" height="1252.160897pt" viewBox="0 0 961.698874 1252.160897"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(-521.000000,1664.561816) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M10300 16643 c-314 -15 -716 -106 -967 -219 -158 -71 -235 -109 -308
|
||||
-151 -623 -361 -1100 -916 -1393 -1623 -62 -150 -88 -222 -117 -325 -10 -33
|
||||
-23 -80 -30 -105 -7 -25 -18 -65 -23 -90 -6 -25 -18 -74 -26 -110 -25 -106
|
||||
-42 -209 -76 -465 -5 -38 -16 -275 -25 -525 -15 -461 -39 -871 -56 -990 -5
|
||||
-36 -16 -117 -24 -180 -21 -162 -77 -466 -110 -605 -86 -359 -182 -695 -272
|
||||
-950 -170 -483 -375 -929 -618 -1345 -131 -227 -160 -271 -451 -706 -533 -795
|
||||
-594 -914 -594 -1156 0 -295 169 -481 495 -544 106 -20 450 -24 555 -6 36 6
|
||||
110 18 165 28 55 9 195 43 310 76 458 131 515 140 587 85 141 -107 181 -295
|
||||
157 -743 -18 -354 -8 -504 42 -619 23 -52 97 -152 138 -184 49 -39 140 -80
|
||||
195 -87 90 -13 231 35 340 113 88 64 193 171 348 355 82 98 160 185 174 194
|
||||
24 16 26 16 60 -17 75 -72 141 -219 234 -519 92 -297 161 -505 171 -515 5 -5
|
||||
9 -15 9 -23 0 -22 109 -242 150 -303 129 -189 329 -291 507 -259 108 19 216
|
||||
76 304 162 128 123 327 410 665 958 144 235 219 330 260 330 33 0 66 -36 196
|
||||
-211 195 -263 341 -411 463 -472 166 -81 375 -47 505 83 133 133 186 305 210
|
||||
685 26 408 50 542 121 691 57 118 138 184 225 184 44 0 194 -47 289 -90 366
|
||||
-168 662 -267 943 -316 140 -25 343 -16 439 18 81 29 186 99 232 155 58 71
|
||||
110 191 121 282 30 234 -26 391 -454 1284 -36 76 -66 140 -66 142 0 3 -11 28
|
||||
-24 57 -285 623 -511 1324 -632 1963 -15 80 -18 97 -49 295 -71 453 -96 775
|
||||
-102 1295 -5 429 3 644 39 1030 19 203 16 790 -5 957 -23 181 -43 315 -63 413
|
||||
-9 44 -18 91 -21 105 -24 119 -106 379 -174 548 -134 336 -378 728 -609 977
|
||||
-317 343 -639 580 -1017 749 -213 95 -366 141 -648 196 -164 31 -503 53 -695
|
||||
43z m400 -414 c206 -19 408 -61 592 -125 124 -43 392 -172 520 -250 461 -283
|
||||
863 -763 1073 -1279 20 -49 40 -99 45 -110 23 -49 90 -260 113 -355 92 -375
|
||||
108 -518 108 -945 -1 -181 -3 -343 -6 -360 -6 -34 -32 -427 -45 -675 -31 -593
|
||||
13 -1315 120 -1950 45 -268 105 -561 150 -730 5 -19 23 -89 40 -155 17 -66 34
|
||||
-131 39 -145 4 -14 16 -50 25 -80 26 -90 52 -166 111 -335 31 -88 60 -171 65
|
||||
-185 16 -53 94 -251 157 -400 101 -241 146 -341 308 -675 74 -153 124 -263
|
||||
246 -531 32 -71 69 -191 69 -224 0 -47 -36 -107 -82 -137 -38 -26 -47 -27
|
||||
-142 -26 -189 3 -420 72 -911 271 -333 135 -539 176 -676 136 -121 -35 -258
|
||||
-140 -338 -257 -51 -75 -116 -222 -144 -327 -33 -120 -68 -395 -82 -635 -9
|
||||
-139 -23 -272 -36 -322 -13 -49 -72 -123 -98 -123 -49 0 -183 144 -356 385
|
||||
-96 133 -240 281 -314 323 -104 59 -241 63 -345 10 -46 -23 -175 -149 -238
|
||||
-233 -37 -50 -115 -167 -293 -445 -12 -19 -62 -100 -111 -180 -126 -207 -240
|
||||
-368 -369 -518 -27 -32 -78 -62 -104 -62 -56 0 -132 62 -170 137 -33 67 -48
|
||||
111 -141 413 -47 155 -128 397 -155 465 -64 163 -87 216 -120 285 -81 165
|
||||
-193 299 -290 344 -43 21 -69 26 -129 26 -146 0 -231 -64 -560 -420 -153 -165
|
||||
-219 -229 -252 -245 -32 -15 -37 -15 -59 -1 -38 25 -50 121 -36 285 16 189 14
|
||||
474 -4 620 -34 274 -106 444 -244 573 -181 171 -406 182 -868 43 -369 -112
|
||||
-653 -155 -885 -134 -136 12 -178 28 -223 81 -25 29 -27 38 -23 98 3 54 12 84
|
||||
49 161 54 111 202 351 331 534 463 660 782 1207 981 1680 30 72 64 151 75 175
|
||||
62 139 215 576 292 830 29 96 114 426 135 522 24 113 70 359 86 463 32 211 43
|
||||
294 74 545 19 155 51 640 70 1060 8 190 20 379 25 420 14 98 38 245 50 295 74
|
||||
312 132 504 205 675 48 114 181 367 250 479 184 298 468 595 753 789 67 45
|
||||
124 82 127 82 2 0 28 15 57 34 94 60 335 170 493 224 67 24 264 73 365 91 162
|
||||
30 450 39 650 20z"/>
|
||||
<path d="M9079 13621 c-58 -12 -122 -56 -190 -130 -77 -82 -121 -153 -165
|
||||
-266 -80 -200 -107 -358 -107 -625 1 -183 3 -219 27 -335 50 -244 125 -405
|
||||
236 -509 127 -119 249 -112 386 22 131 127 228 326 274 561 19 95 40 312 40
|
||||
406 -1 213 -57 471 -138 631 -96 189 -222 275 -363 245z"/>
|
||||
<path d="M11395 13541 c-145 -38 -289 -221 -372 -471 -91 -275 -111 -549 -63
|
||||
-870 39 -260 149 -494 272 -578 309 -212 686 347 688 1019 1 425 -132 778
|
||||
-331 879 -44 23 -146 34 -194 21z"/>
|
||||
<path d="M9829 11141 c-75 -14 -125 -48 -157 -106 -25 -44 -23 -378 2 -525 30
|
||||
-171 82 -306 152 -390 62 -73 186 -100 271 -59 128 62 240 251 303 514 37 156
|
||||
52 241 57 332 l6 92 -34 34 c-39 39 -122 73 -227 93 -91 17 -311 26 -373 15z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
BIN
vite/src/images/logo.png
Normal file
BIN
vite/src/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
31
vite/src/index.css
Normal file
31
vite/src/index.css
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/* frontend/src/index.css */
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #2E2F34; /* Ensure the global background is dark */
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Example scrollbars - optional, to match a dark theme. */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #3B3C41;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
1
vite/src/logo.svg
Normal file
1
vite/src/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
9
vite/src/main.jsx
Normal file
9
vite/src/main.jsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
58
vite/src/mantineTheme.jsx
Normal file
58
vite/src/mantineTheme.jsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { createTheme, MantineProvider, rem } from '@mantine/core';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
background: {
|
||||
default: '#18181b', // Global background color (Tailwind zinc-900)
|
||||
paper: '#27272a', // Paper background (Tailwind zinc-800)
|
||||
},
|
||||
primary: {
|
||||
main: '#4A90E2',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
main: '#F5A623',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
text: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#d4d4d8', // Updated secondary text color (Tailwind zinc-300)
|
||||
},
|
||||
// Custom colors for components (chip buttons, borders, etc.)
|
||||
custom: {
|
||||
// For chip buttons:
|
||||
greenMain: '#90C43E',
|
||||
greenHoverBg: 'rgba(144,196,62,0.1)',
|
||||
|
||||
indigoMain: '#4F39F6',
|
||||
indigoHoverBg: 'rgba(79,57,246,0.1)',
|
||||
|
||||
greyBorder: '#707070',
|
||||
greyHoverBg: 'rgba(112,112,112,0.1)',
|
||||
greyText: '#a0a0a0',
|
||||
|
||||
// Common border colors:
|
||||
borderDefault: '#3f3f46', // Tailwind zinc-700
|
||||
borderHover: '#5f5f66', // Approximate Tailwind zinc-600
|
||||
|
||||
// For the "Add" button:
|
||||
successBorder: '#00a63e',
|
||||
successBg: '#0d542b',
|
||||
successBgHover: '#0a4020',
|
||||
successIcon: '#05DF72',
|
||||
},
|
||||
},
|
||||
|
||||
custom: {
|
||||
sidebar: {
|
||||
activeBackground: 'rgba(21, 69, 62, 0.67)',
|
||||
activeBorder: '#14917e',
|
||||
hoverBackground: '#27272a',
|
||||
hoverBorder: '#3f3f46',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
43
vite/src/pages/Channels.jsx
Normal file
43
vite/src/pages/Channels.jsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useState } from 'react';
|
||||
import ChannelsTable from '../components/tables/ChannelsTable';
|
||||
import StreamsTable from '../components/tables/StreamsTable';
|
||||
import { Box, Grid } from '@mantine/core';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh', // Full viewport height
|
||||
paddingTop: 0, // Top padding
|
||||
paddingBottom: 1, // Bottom padding
|
||||
paddingRight: 0.5,
|
||||
paddingLeft: 0,
|
||||
boxSizing: 'border-box', // Include padding in height calculation
|
||||
overflow: 'hidden', // Prevent parent scrolling
|
||||
}}
|
||||
>
|
||||
<ChannelsTable />
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh', // Full viewport height
|
||||
paddingTop: 0, // Top padding
|
||||
paddingBottom: 1, // Bottom padding
|
||||
paddingRight: 0,
|
||||
paddingLeft: 0.5,
|
||||
boxSizing: 'border-box', // Include padding in height calculation
|
||||
overflow: 'hidden', // Prevent parent scrolling
|
||||
}}
|
||||
>
|
||||
<StreamsTable />
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsPage;
|
||||
27
vite/src/pages/Dashboard.jsx
Normal file
27
vite/src/pages/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// src/components/Dashboard.js
|
||||
import React, { useState } from "react";
|
||||
|
||||
const Dashboard = () => {
|
||||
const [newStream, setNewStream] = useState("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard Page</h1>
|
||||
<input
|
||||
type="text"
|
||||
value={newStream}
|
||||
onChange={(e) => setNewStream(e.target.value)}
|
||||
placeholder="Enter Stream"
|
||||
/>
|
||||
|
||||
<h3>Streams:</h3>
|
||||
<ul>
|
||||
{state.streams.map((stream, index) => (
|
||||
<li key={index}>{stream}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
27
vite/src/pages/EPG.jsx
Normal file
27
vite/src/pages/EPG.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import EPGsTable from '../components/tables/EPGsTable';
|
||||
|
||||
const EPGPage = () => {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '95vh',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box style={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<EPGsTable />
|
||||
</Box>
|
||||
|
||||
<Box style={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<UserAgentsTable />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPGPage;
|
||||
532
vite/src/pages/Guide.jsx
Normal file
532
vite/src/pages/Guide.jsx
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
// frontend/src/pages/Guide.js
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Stack,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Slide,
|
||||
CircularProgress,
|
||||
Backdrop,
|
||||
} from '@mui/material';
|
||||
import dayjs from 'dayjs';
|
||||
import API from '../api';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import logo from '../images/logo.png';
|
||||
import useVideoStore from '../store/useVideoStore'; // NEW import
|
||||
import useAlertStore from '../store/alerts';
|
||||
import useSettingsStore from '../store/settings';
|
||||
|
||||
/** Layout constants */
|
||||
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
|
||||
const PROGRAM_HEIGHT = 90; // Height of each channel row
|
||||
const HOUR_WIDTH = 300; // The width for a 1-hour block
|
||||
const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
|
||||
const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
|
||||
|
||||
// Modal size constants
|
||||
const MODAL_WIDTH = 600;
|
||||
const MODAL_HEIGHT = 400;
|
||||
|
||||
// Slide transition for Dialog
|
||||
const Transition = React.forwardRef(function Transition(props, ref) {
|
||||
return <Slide direction="up" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
export default function TVChannelGuide({ startDate, endDate }) {
|
||||
const { channels } = useChannelsStore();
|
||||
|
||||
const [programs, setPrograms] = useState([]);
|
||||
const [guideChannels, setGuideChannels] = useState([]);
|
||||
const [now, setNow] = useState(dayjs());
|
||||
const [selectedProgram, setSelectedProgram] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { showAlert } = useAlertStore();
|
||||
const {
|
||||
environment: { env_mode },
|
||||
} = useSettingsStore();
|
||||
|
||||
const guideRef = useRef(null);
|
||||
|
||||
// Load program data once
|
||||
useEffect(() => {
|
||||
if (!channels || channels.length === 0) {
|
||||
console.warn('No channels provided or empty channels array');
|
||||
showAlert('No channels available', 'error');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
console.log('Fetching program grid...');
|
||||
const fetched = await API.getGrid(); // GETs your EPG grid
|
||||
console.log(`Received ${fetched.length} programs`);
|
||||
|
||||
// Unique tvg_ids from returned programs
|
||||
const programIds = [...new Set(fetched.map((p) => p.tvg_id))];
|
||||
|
||||
// Filter your Redux/Zustand channels by matching tvg_id
|
||||
const filteredChannels = channels.filter((ch) =>
|
||||
programIds.includes(ch.tvg_id)
|
||||
);
|
||||
console.log(
|
||||
`found ${filteredChannels.length} channels with matching tvg_ids`
|
||||
);
|
||||
|
||||
setGuideChannels(filteredChannels);
|
||||
setPrograms(fetched);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchPrograms();
|
||||
}, [channels]);
|
||||
|
||||
// Use start/end from props or default to "today at midnight" +24h
|
||||
const defaultStart = dayjs(startDate || dayjs().startOf('day'));
|
||||
const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour');
|
||||
|
||||
// Expand timeline if needed based on actual earliest/ latest program
|
||||
const earliestProgramStart = useMemo(() => {
|
||||
if (!programs.length) return defaultStart;
|
||||
return programs.reduce((acc, p) => {
|
||||
const s = dayjs(p.start_time);
|
||||
return s.isBefore(acc) ? s : acc;
|
||||
}, defaultStart);
|
||||
}, [programs, defaultStart]);
|
||||
|
||||
const latestProgramEnd = useMemo(() => {
|
||||
if (!programs.length) return defaultEnd;
|
||||
return programs.reduce((acc, p) => {
|
||||
const e = dayjs(p.end_time);
|
||||
return e.isAfter(acc) ? e : acc;
|
||||
}, defaultEnd);
|
||||
}, [programs, defaultEnd]);
|
||||
|
||||
const start = earliestProgramStart.isBefore(defaultStart)
|
||||
? earliestProgramStart
|
||||
: defaultStart;
|
||||
const end = latestProgramEnd.isAfter(defaultEnd)
|
||||
? latestProgramEnd
|
||||
: defaultEnd;
|
||||
|
||||
// Time increments in 15-min steps (for placing programs)
|
||||
const programTimeline = useMemo(() => {
|
||||
const times = [];
|
||||
let current = start;
|
||||
while (current.isBefore(end)) {
|
||||
times.push(current);
|
||||
current = current.add(MINUTE_INCREMENT, 'minute');
|
||||
}
|
||||
return times;
|
||||
}, [start, end]);
|
||||
|
||||
// Hourly marks
|
||||
const hourTimeline = useMemo(() => {
|
||||
const hours = [];
|
||||
let current = start;
|
||||
while (current.isBefore(end)) {
|
||||
hours.push(current);
|
||||
current = current.add(1, 'hour');
|
||||
}
|
||||
return hours;
|
||||
}, [start, end]);
|
||||
|
||||
// Scroll to "now" on load
|
||||
useEffect(() => {
|
||||
if (guideRef.current) {
|
||||
const nowOffset = dayjs().diff(start, 'minute');
|
||||
const scrollPosition =
|
||||
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
|
||||
MINUTE_BLOCK_WIDTH;
|
||||
guideRef.current.scrollLeft = Math.max(scrollPosition, 0);
|
||||
}
|
||||
}, [programs, start]);
|
||||
|
||||
// Update “now” every 60s
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setNow(dayjs());
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Pixel offset for the “now” vertical line
|
||||
const nowPosition = useMemo(() => {
|
||||
if (now.isBefore(start) || now.isAfter(end)) return -1;
|
||||
const minutesSinceStart = now.diff(start, 'minute');
|
||||
return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
}, [now, start, end]);
|
||||
|
||||
// Helper: find channel by tvg_id
|
||||
function findChannelByTvgId(tvgId) {
|
||||
return guideChannels.find((ch) => ch.tvg_id === tvgId);
|
||||
}
|
||||
|
||||
// The “Watch Now” click => show floating video
|
||||
const { showVideo } = useVideoStore(); // or useVideoStore()
|
||||
function handleWatchStream(program) {
|
||||
const matched = findChannelByTvgId(program.tvg_id);
|
||||
if (!matched) {
|
||||
console.warn(`No channel found for tvg_id=${program.tvg_id}`);
|
||||
return;
|
||||
}
|
||||
// Build a playable stream URL for that channel
|
||||
let vidUrl = `/output/stream/${matched.channel_number}/`;
|
||||
if (env_mode == 'dev') {
|
||||
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
|
||||
}
|
||||
|
||||
showVideo(vidUrl);
|
||||
|
||||
// Optionally close the modal
|
||||
setSelectedProgram(null);
|
||||
}
|
||||
|
||||
// On program click, open the details modal
|
||||
function handleProgramClick(program, event) {
|
||||
// Optionally scroll that element into view or do something else
|
||||
event.currentTarget.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'center',
|
||||
});
|
||||
setSelectedProgram(program);
|
||||
}
|
||||
|
||||
// Close the modal
|
||||
function handleCloseModal() {
|
||||
setSelectedProgram(null);
|
||||
}
|
||||
|
||||
// Renders each program block
|
||||
function renderProgram(program, channelStart) {
|
||||
const programKey = `${program.tvg_id}-${program.start_time}`;
|
||||
const programStart = dayjs(program.start_time);
|
||||
const programEnd = dayjs(program.end_time);
|
||||
const startOffsetMinutes = programStart.diff(channelStart, 'minute');
|
||||
const durationMinutes = programEnd.diff(programStart, 'minute');
|
||||
const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
|
||||
// Highlight if currently live
|
||||
const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={programKey}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: leftPx,
|
||||
top: 0,
|
||||
width: widthPx,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={(e) => handleProgramClick(program, e)}
|
||||
>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
left: 2,
|
||||
width: widthPx - 4,
|
||||
top: 2,
|
||||
height: PROGRAM_HEIGHT - 4,
|
||||
p: 1,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
borderRadius: '8px',
|
||||
background: isLive
|
||||
? 'linear-gradient(to right, #1e3a8a, #2c5282)'
|
||||
: 'linear-gradient(to right, #2d3748, #2d3748)',
|
||||
color: '#fff',
|
||||
transition: 'background 0.3s ease',
|
||||
'&:hover': {
|
||||
background: isLive
|
||||
? 'linear-gradient(to right, #1e3a8a, #2a4365)'
|
||||
: 'linear-gradient(to right, #2d3748, #1a202c)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" noWrap sx={{ fontWeight: 'bold' }}>
|
||||
{program.title}
|
||||
</Typography>
|
||||
<Typography variant="overline" noWrap>
|
||||
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Backdrop
|
||||
sx={{
|
||||
// color: '#fff',
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
position: 'fixed', // Ensure it covers the entire page
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
open={loading}
|
||||
>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#1a202c',
|
||||
color: '#fff',
|
||||
fontFamily: 'Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* Sticky top bar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2d3748',
|
||||
color: '#fff',
|
||||
p: 2,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 999,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
TV Guide
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{now.format('dddd, MMMM D, YYYY • h:mm A')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Main layout */}
|
||||
<Stack direction="row">
|
||||
{/* Channel Logos Column */}
|
||||
<Box sx={{ backgroundColor: '#2d3748', color: '#fff' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: CHANNEL_WIDTH,
|
||||
height: '40px',
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
/>
|
||||
{guideChannels.map((channel) => (
|
||||
<Box
|
||||
key={channel.channel_name}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
height: PROGRAM_HEIGHT,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: CHANNEL_WIDTH,
|
||||
display: 'flex',
|
||||
p: 1,
|
||||
justifyContent: 'center',
|
||||
maxWidth: CHANNEL_WIDTH * 0.8,
|
||||
maxHeight: PROGRAM_HEIGHT * 0.8,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={channel.logo_url || logo}
|
||||
alt={channel.channel_name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Timeline & Program Blocks */}
|
||||
<Box
|
||||
ref={guideRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Sticky timeline header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#171923',
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, display: 'flex' }}>
|
||||
{hourTimeline.map((time, hourIndex) => (
|
||||
<Box
|
||||
key={time.format()}
|
||||
sx={{
|
||||
width: HOUR_WIDTH,
|
||||
height: '40px',
|
||||
position: 'relative',
|
||||
color: '#a0aec0',
|
||||
borderRight: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: hourIndex === 0 ? 4 : 'calc(50% - 16px)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
{time.format('h:mma')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: '1px',
|
||||
height: '10px',
|
||||
backgroundColor: '#718096',
|
||||
marginRight: i < 3 ? HOUR_WIDTH / 4 - 1 + 'px' : 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Now line */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{nowPosition >= 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: nowPosition,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '2px',
|
||||
backgroundColor: '#38b2ac',
|
||||
zIndex: 15,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Channel rows */}
|
||||
{guideChannels.map((channel) => {
|
||||
const channelPrograms = programs.filter(
|
||||
(p) => p.tvg_id === channel.tvg_id
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
key={channel.channel_name}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
minHeight: PROGRAM_HEIGHT,
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
||||
{channelPrograms.map((prog) => renderProgram(prog, start))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Modal for program details */}
|
||||
<Dialog
|
||||
open={Boolean(selectedProgram)}
|
||||
onClose={handleCloseModal}
|
||||
TransitionComponent={Transition}
|
||||
keepMounted
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: MODAL_WIDTH,
|
||||
height: MODAL_HEIGHT,
|
||||
m: 'auto',
|
||||
backgroundColor: '#1a202c',
|
||||
border: '2px solid #718096',
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiDialog-container': {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{selectedProgram && (
|
||||
<>
|
||||
<DialogTitle sx={{ color: '#fff' }}>
|
||||
{selectedProgram.title}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ color: '#a0aec0' }}>
|
||||
<Typography variant="caption" display="block">
|
||||
{dayjs(selectedProgram.start_time).format('h:mma')} -{' '}
|
||||
{dayjs(selectedProgram.end_time).format('h:mma')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#fff' }}>
|
||||
{selectedProgram.description || 'No description available.'}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{/* Only show the Watch button if currently live */}
|
||||
{now.isAfter(dayjs(selectedProgram.start_time)) &&
|
||||
now.isBefore(dayjs(selectedProgram.end_time)) && (
|
||||
<Button
|
||||
onClick={() => handleWatchStream(selectedProgram)}
|
||||
sx={{ color: '#38b2ac' }}
|
||||
>
|
||||
Watch Now
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCloseModal} sx={{ color: '#38b2ac' }}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
14
vite/src/pages/Home.jsx
Normal file
14
vite/src/pages/Home.jsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// src/components/Home.js
|
||||
import React, { useState } from "react";
|
||||
|
||||
const Home = () => {
|
||||
const [newChannel, setNewChannel] = useState("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Home Page</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
8
vite/src/pages/Login.jsx
Normal file
8
vite/src/pages/Login.jsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import LoginForm from '../components/forms/LoginForm';
|
||||
|
||||
const Login = () => {
|
||||
return <LoginForm />;
|
||||
};
|
||||
|
||||
export default Login;
|
||||
34
vite/src/pages/M3U.jsx
Normal file
34
vite/src/pages/M3U.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React, { useState } from 'react';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import M3UsTable from '../components/tables/M3UsTable';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
const M3UPage = () => {
|
||||
const isLoading = useUserAgentsStore((state) => state.isLoading);
|
||||
const error = useUserAgentsStore((state) => state.error);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<M3UsTable />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<UserAgentsTable />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UPage;
|
||||
146
vite/src/pages/Settings.jsx
Normal file
146
vite/src/pages/Settings.jsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import API from '../api';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
import { Button, Center, Flex, Paper, Select, Title } from '@mantine/core';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { settings } = useSettingsStore();
|
||||
const { userAgents } = useUserAgentsStore();
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
|
||||
// Add your region choices here:
|
||||
const regionChoices = [
|
||||
{ value: 'us', label: 'US' },
|
||||
{ value: 'uk', label: 'UK' },
|
||||
{ value: 'nl', label: 'NL' },
|
||||
{ value: 'de', label: 'DE' },
|
||||
// Add more if needed
|
||||
];
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
'default-user-agent': '',
|
||||
'default-stream-profile': '',
|
||||
'preferred-region': '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
'default-user-agent': Yup.string().required('User-Agent is required'),
|
||||
'default-stream-profile': Yup.string().required(
|
||||
'Stream Profile is required'
|
||||
),
|
||||
// The region is optional or required as you prefer
|
||||
// 'preferred-region': Yup.string().required('Region is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
const changedSettings = {};
|
||||
for (const settingKey in values) {
|
||||
// If the user changed the setting’s value from what’s in the DB:
|
||||
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
|
||||
changedSettings[settingKey] = values[settingKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Update each changed setting in the backend
|
||||
for (const updatedKey in changedSettings) {
|
||||
await API.updateSetting({
|
||||
...settings[updatedKey],
|
||||
value: changedSettings[updatedKey],
|
||||
});
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
// Don’t necessarily resetForm, in case the user wants to see new values
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize form values once settings / userAgents / profiles are loaded
|
||||
useEffect(() => {
|
||||
formik.setValues(
|
||||
Object.values(settings).reduce((acc, setting) => {
|
||||
// If the setting’s value is numeric, parse it
|
||||
// Otherwise, just store as string
|
||||
const possibleNumber = parseInt(setting.value, 10);
|
||||
acc[setting.key] = isNaN(possibleNumber)
|
||||
? setting.value
|
||||
: possibleNumber;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
// eslint-disable-next-line
|
||||
}, [settings, userAgents, streamProfiles]);
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{ padding: 30, width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Title order={4} align="center">
|
||||
Settings
|
||||
</Title>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<Select
|
||||
id={settings['default-user-agent']?.id}
|
||||
name={settings['default-user-agent']?.key}
|
||||
label={settings['default-user-agent']?.name}
|
||||
value={formik.values['default-user-agent'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched['default-user-agent']}
|
||||
data={userAgents.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.user_agent_name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id={settings['default-user-agent']?.id}
|
||||
name={settings['default-user-agent']?.key}
|
||||
label={settings['default-user-agent']?.name}
|
||||
value={formik.values['default-user-agent'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched['default-user-agent']}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.profile_name,
|
||||
}))}
|
||||
/>
|
||||
{/* <Select
|
||||
labelId="region-label"
|
||||
id={settings['preferred-region'].id}
|
||||
name={settings['preferred-region'].key}
|
||||
label={settings['preferred-region'].name}
|
||||
value={formik.values['preferred-region'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
data={regionChoices.map((r) => ({
|
||||
label: r.label,
|
||||
value: `${r.value}`,
|
||||
}))}
|
||||
/> */}
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
8
vite/src/pages/StreamProfiles.jsx
Normal file
8
vite/src/pages/StreamProfiles.jsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
|
||||
|
||||
const StreamProfilesPage = () => {
|
||||
return <StreamProfilesTable />;
|
||||
};
|
||||
|
||||
export default StreamProfilesPage;
|
||||
24
vite/src/store/alerts.jsx
Normal file
24
vite/src/store/alerts.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// frontend/src/store/useAlertStore.js
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Global store to track whether a floating video is visible and which URL is playing.
|
||||
*/
|
||||
const useAlertStore = create((set) => ({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'info',
|
||||
|
||||
showAlert: (message, severity = 'info') =>
|
||||
set({
|
||||
open: true,
|
||||
message,
|
||||
severity,
|
||||
}),
|
||||
|
||||
hideAlert: () => {
|
||||
set({ open: false });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useAlertStore;
|
||||
129
vite/src/store/auth.jsx
Normal file
129
vite/src/store/auth.jsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
import useChannelsStore from './channels';
|
||||
import useUserAgentsStore from './userAgents';
|
||||
import usePlaylistsStore from './playlists';
|
||||
import useEPGsStore from './epgs';
|
||||
import useStreamProfilesStore from './streamProfiles';
|
||||
import useSettingsStore from './settings';
|
||||
|
||||
const decodeToken = (token) => {
|
||||
if (!token) return null;
|
||||
const payload = token.split('.')[1];
|
||||
const decodedPayload = JSON.parse(atob(payload));
|
||||
return decodedPayload.exp;
|
||||
};
|
||||
|
||||
const isTokenExpired = (expirationTime) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return now >= expirationTime;
|
||||
};
|
||||
|
||||
const useAuthStore = create((set, get) => ({
|
||||
accessToken: localStorage.getItem('accessToken') || null,
|
||||
refreshToken: localStorage.getItem('refreshToken') || null,
|
||||
tokenExpiration: localStorage.getItem('tokenExpiration') || null,
|
||||
isAuthenticated: false,
|
||||
|
||||
setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
|
||||
|
||||
initData: async () => {
|
||||
await Promise.all([
|
||||
useChannelsStore.getState().fetchChannels(),
|
||||
useChannelsStore.getState().fetchChannelGroups(),
|
||||
useUserAgentsStore.getState().fetchUserAgents(),
|
||||
usePlaylistsStore.getState().fetchPlaylists(),
|
||||
useEPGsStore.getState().fetchEPGs(),
|
||||
useStreamProfilesStore.getState().fetchProfiles(),
|
||||
useSettingsStore.getState().fetchSettings(),
|
||||
]);
|
||||
},
|
||||
|
||||
getToken: async () => {
|
||||
const tokenExpiration = localStorage.getItem('tokenExpiration');
|
||||
let accessToken = null;
|
||||
if (isTokenExpired(tokenExpiration)) {
|
||||
accessToken = await get().refreshToken();
|
||||
} else {
|
||||
accessToken = localStorage.getItem('accessToken');
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
},
|
||||
|
||||
// Action to login
|
||||
login: async ({ username, password }) => {
|
||||
try {
|
||||
const response = await API.login(username, password);
|
||||
if (response.access) {
|
||||
const expiration = decodeToken(response.access);
|
||||
set({
|
||||
accessToken: response.access,
|
||||
refreshToken: response.refresh,
|
||||
tokenExpiration: expiration, // 1 hour from now
|
||||
isAuthenticated: true,
|
||||
});
|
||||
// Store in localStorage
|
||||
localStorage.setItem('accessToken', response.access);
|
||||
localStorage.setItem('refreshToken', response.refresh);
|
||||
localStorage.setItem('tokenExpiration', expiration);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Action to refresh the token
|
||||
refreshToken: async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) return;
|
||||
|
||||
try {
|
||||
const data = await API.refreshToken(refreshToken);
|
||||
if (data.access) {
|
||||
set({
|
||||
accessToken: data.access,
|
||||
tokenExpiration: decodeToken(data.access),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
localStorage.setItem('accessToken', data.access);
|
||||
localStorage.setItem('tokenExpiration', decodeToken(data.access));
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
get().logout();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
// Action to logout
|
||||
logout: () => {
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenExpiration: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('tokenExpiration');
|
||||
},
|
||||
|
||||
initializeAuth: async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken') || null;
|
||||
|
||||
if (refreshToken) {
|
||||
const loggedIn = await get().refreshToken();
|
||||
if (loggedIn) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
export default useAuthStore;
|
||||
89
vite/src/store/channels.jsx
Normal file
89
vite/src/store/channels.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const useChannelsStore = create((set) => ({
|
||||
channels: [],
|
||||
channelGroups: [],
|
||||
channelsPageSelection: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchChannels: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const channels = await api.getChannels();
|
||||
set({
|
||||
channels: channels.reduce((acc, channel) => {
|
||||
acc[channel.id] = channel;
|
||||
return acc;
|
||||
}, {}),
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channels:', error);
|
||||
set({ error: 'Failed to load channels.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchChannelGroups: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const channelGroups = await api.getChannelGroups();
|
||||
set({ channelGroups: channelGroups, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channel groups:', error);
|
||||
set({ error: 'Failed to load channel groups.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addChannel: (newChannel) =>
|
||||
set((state) => ({
|
||||
channels: {
|
||||
...state.channels,
|
||||
[newChannel.id]: newChannel,
|
||||
},
|
||||
})),
|
||||
|
||||
addChannels: (newChannels) =>
|
||||
set((state) => ({
|
||||
channels: {
|
||||
...state.channels,
|
||||
...newChannels,
|
||||
},
|
||||
})),
|
||||
|
||||
updateChannel: (channel) =>
|
||||
set((state) => ({
|
||||
channels: {
|
||||
...state.channels,
|
||||
[channel.id]: channel,
|
||||
},
|
||||
})),
|
||||
|
||||
removeChannels: (channelIds) =>
|
||||
set((state) => {
|
||||
const updatedChannels = { ...state.channels };
|
||||
for (const id of channelIds) {
|
||||
delete updatedChannels[id];
|
||||
}
|
||||
|
||||
return { channels: updatedChannels };
|
||||
}),
|
||||
|
||||
addChannelGroup: (newChannelGroup) =>
|
||||
set((state) => ({
|
||||
channelGroups: [...state.channelGroups, newChannelGroup],
|
||||
})),
|
||||
|
||||
updateChannelGroup: (channelGroup) =>
|
||||
set((state) => ({
|
||||
channelGroups: state.channelGroups.map((group) =>
|
||||
group.id === channelGroup.id ? channelGroup : group
|
||||
),
|
||||
})),
|
||||
|
||||
setChannelsPageSelection: (channelsPageSelection) =>
|
||||
set((state) => ({ channelsPageSelection })),
|
||||
}));
|
||||
|
||||
export default useChannelsStore;
|
||||
31
vite/src/store/epgs.jsx
Normal file
31
vite/src/store/epgs.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { create } from "zustand";
|
||||
import api from "../api";
|
||||
|
||||
const useEPGsStore = create((set) => ({
|
||||
epgs: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchEPGs: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const epgs = await api.getEPGs();
|
||||
set({ epgs: epgs, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch epgs:", error);
|
||||
set({ error: "Failed to load epgs.", isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addEPG: (newPlaylist) =>
|
||||
set((state) => ({
|
||||
epgs: [...state.epgs, newPlaylist],
|
||||
})),
|
||||
|
||||
removeEPGs: (epgIds) =>
|
||||
set((state) => ({
|
||||
epgs: state.epgs.filter((epg) => !epgIds.includes(epg.id)),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useEPGsStore;
|
||||
65
vite/src/store/playlists.jsx
Normal file
65
vite/src/store/playlists.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const usePlaylistsStore = create((set) => ({
|
||||
playlists: [],
|
||||
profiles: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchPlaylists: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const playlists = await api.getPlaylists();
|
||||
set({
|
||||
playlists: playlists,
|
||||
isLoading: false,
|
||||
profiles: playlists.reduce((acc, playlist) => {
|
||||
acc[playlist.id] = playlist.profiles;
|
||||
return acc;
|
||||
}, {}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch playlists:', error);
|
||||
set({ error: 'Failed to load playlists.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addPlaylist: (newPlaylist) =>
|
||||
set((state) => ({
|
||||
playlists: [...state.playlists, newPlaylist],
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[newPlaylist.id]: newPlaylist.profiles,
|
||||
},
|
||||
})),
|
||||
|
||||
updatePlaylist: (playlist) =>
|
||||
set((state) => ({
|
||||
playlists: state.playlists.map((pl) =>
|
||||
pl.id === playlist.id ? playlist : pl
|
||||
),
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[playlist.id]: playlist.profiles,
|
||||
},
|
||||
})),
|
||||
|
||||
updateProfiles: (playlistId, profiles) =>
|
||||
set((state) => ({
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[playlistId]: profiles,
|
||||
},
|
||||
})),
|
||||
|
||||
removePlaylists: (playlistIds) =>
|
||||
set((state) => ({
|
||||
playlists: state.playlists.filter(
|
||||
(playlist) => !playlistIds.includes(playlist.id)
|
||||
),
|
||||
// @TODO: remove playlist profiles here
|
||||
})),
|
||||
}));
|
||||
|
||||
export default usePlaylistsStore;
|
||||
34
vite/src/store/settings.jsx
Normal file
34
vite/src/store/settings.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const useSettingsStore = create((set) => ({
|
||||
settings: {},
|
||||
environment: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchSettings: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
const env = await api.getEnvironmentSettings();
|
||||
set({
|
||||
settings: settings.reduce((acc, setting) => {
|
||||
acc[setting.key] = setting;
|
||||
return acc;
|
||||
}, {}),
|
||||
isLoading: false,
|
||||
environment: env,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: 'Failed to load settings.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateSetting: (setting) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, [setting.key]: setting },
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useSettingsStore;
|
||||
40
vite/src/store/streamProfiles.jsx
Normal file
40
vite/src/store/streamProfiles.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const useStreamProfilesStore = create((set) => ({
|
||||
profiles: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchProfiles: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const profiles = await api.getStreamProfiles();
|
||||
set({ profiles: profiles, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profiles:', error);
|
||||
set({ error: 'Failed to load profiles.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addStreamProfile: (profile) =>
|
||||
set((state) => ({
|
||||
profiles: [...state.profiles, profile],
|
||||
})),
|
||||
|
||||
updateStreamProfile: (profile) =>
|
||||
set((state) => ({
|
||||
profiles: state.profiles.map((prof) =>
|
||||
prof.id === profile.id ? profile : prof
|
||||
),
|
||||
})),
|
||||
|
||||
removeStreamProfiles: (propfileIds) =>
|
||||
set((state) => ({
|
||||
profiles: state.profiles.filter(
|
||||
(profile) => !propfileIds.includes(profile.id)
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useStreamProfilesStore;
|
||||
41
vite/src/store/streams.jsx
Normal file
41
vite/src/store/streams.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const useStreamsStore = create((set) => ({
|
||||
streams: [],
|
||||
count: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchStreams: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.getStreams();
|
||||
set({
|
||||
streams: response.results,
|
||||
count: response.count,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch streams:', error);
|
||||
set({ error: 'Failed to load streams.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addStream: (stream) =>
|
||||
set((state) => ({
|
||||
streams: [...state.streams, stream],
|
||||
})),
|
||||
|
||||
updateStream: (stream) =>
|
||||
set((state) => ({
|
||||
streams: state.streams.map((st) => (st.id === stream.id ? stream : st)),
|
||||
})),
|
||||
|
||||
removeStreams: (streamIds) =>
|
||||
set((state) => ({
|
||||
streams: state.streams.filter((stream) => !streamIds.includes(stream.id)),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useStreamsStore;
|
||||
24
vite/src/store/useVideoStore.jsx
Normal file
24
vite/src/store/useVideoStore.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// frontend/src/store/useVideoStore.js
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Global store to track whether a floating video is visible and which URL is playing.
|
||||
*/
|
||||
const useVideoStore = create((set) => ({
|
||||
isVisible: false,
|
||||
streamUrl: null,
|
||||
|
||||
showVideo: (url) =>
|
||||
set({
|
||||
isVisible: true,
|
||||
streamUrl: url,
|
||||
}),
|
||||
|
||||
hideVideo: () =>
|
||||
set({
|
||||
isVisible: false,
|
||||
streamUrl: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
export default useVideoStore;
|
||||
40
vite/src/store/userAgents.jsx
Normal file
40
vite/src/store/userAgents.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { create } from "zustand";
|
||||
import api from "../api";
|
||||
|
||||
const useUserAgentsStore = create((set) => ({
|
||||
userAgents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchUserAgents: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const userAgents = await api.getUserAgents();
|
||||
set({ userAgents: userAgents, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch userAgents:", error);
|
||||
set({ error: "Failed to load userAgents.", isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addUserAgent: (userAgent) =>
|
||||
set((state) => ({
|
||||
userAgents: [...state.userAgents, userAgent],
|
||||
})),
|
||||
|
||||
updateUserAgent: (userAgent) =>
|
||||
set((state) => ({
|
||||
userAgents: state.userAgents.map((ua) =>
|
||||
ua.id === userAgent.id ? userAgent : ua,
|
||||
),
|
||||
})),
|
||||
|
||||
removeUserAgents: (userAgentIds) =>
|
||||
set((state) => ({
|
||||
userAgents: state.userAgents.filter(
|
||||
(userAgent) => !userAgentIds.includes(userAgent.id),
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useUserAgentsStore;
|
||||
92
vite/src/theme.jsx
Normal file
92
vite/src/theme.jsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// src/theme.js
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
background: {
|
||||
default: '#18181b', // Global background color (Tailwind zinc-900)
|
||||
paper: '#27272a', // Paper background (Tailwind zinc-800)
|
||||
},
|
||||
primary: {
|
||||
main: '#4A90E2',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
main: '#F5A623',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
text: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#d4d4d8', // Updated secondary text color (Tailwind zinc-300)
|
||||
},
|
||||
// Custom colors for components (chip buttons, borders, etc.)
|
||||
custom: {
|
||||
// For chip buttons:
|
||||
greenMain: '#90C43E',
|
||||
greenHoverBg: 'rgba(144,196,62,0.1)',
|
||||
|
||||
indigoMain: '#4F39F6',
|
||||
indigoHoverBg: 'rgba(79,57,246,0.1)',
|
||||
|
||||
greyBorder: '#707070',
|
||||
greyHoverBg: 'rgba(112,112,112,0.1)',
|
||||
greyText: '#a0a0a0',
|
||||
|
||||
// Common border colors:
|
||||
borderDefault: '#3f3f46', // Tailwind zinc-700
|
||||
borderHover: '#5f5f66', // Approximate Tailwind zinc-600
|
||||
|
||||
// For the "Add" button:
|
||||
successBorder: '#00a63e',
|
||||
successBg: '#0d542b',
|
||||
successBgHover: '#0a4020',
|
||||
successIcon: '#05DF72',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
// Set Inter as the global font
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
h1: { fontSize: '2.5rem', fontWeight: 700 },
|
||||
h2: { fontSize: '2rem', fontWeight: 700 },
|
||||
body1: { fontSize: '1rem' },
|
||||
},
|
||||
spacing: 8,
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 4,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: '#27272a', // Use the same paper color
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: '#18181b',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
custom: {
|
||||
sidebar: {
|
||||
activeBackground: 'rgba(21, 69, 62, 0.67)',
|
||||
activeBorder: '#14917e',
|
||||
hoverBackground: '#27272a',
|
||||
hoverBorder: '#3f3f46',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
53
vite/src/utils.js
Normal file
53
vite/src/utils.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export default {
|
||||
Limiter: (n, list) => {
|
||||
if (!list || !list.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var tail = list.splice(n);
|
||||
var head = list;
|
||||
var resolved = [];
|
||||
var processed = 0;
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
head.forEach(function (x) {
|
||||
var res = x();
|
||||
resolved.push(res);
|
||||
res.then(function (y) {
|
||||
runNext();
|
||||
return y;
|
||||
});
|
||||
});
|
||||
function runNext() {
|
||||
if (processed == tail.length) {
|
||||
resolve(Promise.all(resolved));
|
||||
} else {
|
||||
resolved.push(
|
||||
tail[processed]().then(function (x) {
|
||||
runNext();
|
||||
return x;
|
||||
})
|
||||
);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Custom debounce hook
|
||||
export function useDebounce(value, delay = 500) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(handler); // Cleanup timeout on unmount or value change
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
26
vite/vite.config.js
Normal file
26
vite/vite.config.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
server: {
|
||||
port: 9191,
|
||||
|
||||
// proxy: {
|
||||
// "/api": {
|
||||
// target: "http://localhost:5656", // Backend server
|
||||
// changeOrigin: true,
|
||||
// secure: false, // Set to true if backend uses HTTPS
|
||||
// // rewrite: (path) => path.replace(/^\/api/, ""), // Optional path rewrite
|
||||
// },
|
||||
// "/ws": {
|
||||
// target: "http://localhost:8001", // Backend server
|
||||
// changeOrigin: true,
|
||||
// secure: false, // Set to true if backend uses HTTPS
|
||||
// // rewrite: (path) => path.replace(/^\/api/, ""), // Optional path rewrite
|
||||
// },
|
||||
// },
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue