first run at mantine

This commit is contained in:
dekzter 2025-03-10 20:54:06 -04:00
parent c05e769f09
commit 81978e22d5
68 changed files with 12327 additions and 3 deletions

View file

@ -6,8 +6,9 @@ services:
image: dispatcharr/dispatcharr
container_name: dispatcharr_dev
ports:
- "5656:5656"
- 5656:5656
- 9191:9191
- 8001:8001
volumes:
- ../:/app
environment:

View file

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

View file

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

File diff suppressed because it is too large Load diff

52
vite/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}

View 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

View 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;

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load diff

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -0,0 +1,3 @@
import table from "./table";
export const TableHelper = table;

View 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',
},
},
},
},
};

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

31
vite/src/index.css Normal file
View 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
View 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
View 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
View 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;

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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 settings value from whats 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);
// Dont 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 settings 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;

View 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
View 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
View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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
// },
// },
},
});