Init working UI

This commit is contained in:
Logan Cusano
2025-05-25 23:20:48 -04:00
parent b889210f2b
commit 30707fc0d5
19 changed files with 1458 additions and 1113 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/context/AuthContext';
import LoginPage from '@/components/LoginPage';
import BotsManagement from '@/components/BotsManagement';
import SystemsManagement from '@/components/SystemsManagement';
import { UserRoles } from '@/types';
const AppContent: React.FC = () => {
const { user, loading, logout, hasPermission, token } = useAuth();
const [currentPage, setCurrentPage] = useState<'login' | 'management'>(loading ? 'login' : (user ? 'management' : 'login'));
useEffect(() => {
if (!loading) {
if (user && hasPermission(UserRoles.MOD)) {
setCurrentPage('management');
} else {
setCurrentPage('login');
}
}
}, [user, loading, hasPermission]);
if (loading) {
return <div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">Loading Authentication...</div>;
}
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans">
<header className="flex justify-between items-center p-4 bg-white dark:bg-gray-800 shadow-md">
<h1 className="text-xl font-bold">Radio App Admin</h1>
{user && (
<div className="flex items-center space-x-4">
<span className="text-sm">Logged in as: {user.username} ({user.role})</span>
<Button onClick={() => {
logout();
setCurrentPage('login');
}} variant="outline">Logout</Button>
</div>
)}
</header>
<main className="p-6">
{currentPage === 'login' && <LoginPage />}
{currentPage === 'management' && user && token && hasPermission(UserRoles.MOD) && (
<div className="space-y-8">
<BotsManagement token={token} />
<SystemsManagement token={token} />
</div>
)}
{currentPage === 'management' && user && !hasPermission(UserRoles.MOD) && (
<div className="text-center text-red-500 text-lg">
You do not have permission to view this page. Your role: {user.role}. Required: {UserRoles.MOD}.
<Button onClick={() => setCurrentPage('login')} className="mt-4">Go to Login</Button>
</div>
)}
{currentPage === 'management' && !user && (
<div className="text-center text-orange-500 text-lg">
Session might have expired. Please login again.
<Button onClick={() => setCurrentPage('login')} className="mt-4">Go to Login</Button>
</div>
)}
</main>
</div>
);
}
export default AppContent;

View File

@@ -0,0 +1,425 @@
"use client";
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { API_BASE_URL } from '@/constants/api';
import { DiscordId, System, ErrorResponse } from '@/types'; // Import types
interface BotsManagementProps {
token: string;
}
const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
const [bots, setBots] = useState<string[]>([]);
const [discordIds, setDiscordIds] = useState<DiscordId[]>([]);
const [systems, setSystems] = useState<System[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>('');
const [isAddIdDialogOpen, setIsAddIdDialogOpen] = useState<boolean>(false);
const [newIdData, setNewIdData] = useState<Omit<DiscordId, '_id' | 'guild_ids'> & { guild_ids: string }>({
discord_id: '',
name: '',
token: '',
active: false,
guild_ids: '',
});
const [editingId, setEditingId] = useState<DiscordId | null>(null);
const [isAssignDismissDialogOpen, setIsAssignDismissDialogOpen] = useState<boolean>(false);
const [selectedBotClientId, setSelectedBotClientId] = useState<string>('');
const [selectedSystemId, setSelectedSystemId] = useState<string>('');
const [assignDismissAction, setAssignDismissAction] = useState<'assign' | 'dismiss'>('assign');
const fetchData = async (): Promise<void> => {
setLoading(true);
setError('');
let accumulatedError = '';
try {
const [nodesRes, discordIdsRes, systemsRes] = await Promise.all([
fetch(`${API_BASE_URL}/nodes/`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${API_BASE_URL}/bots/tokens/`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${API_BASE_URL}/systems/`, { headers: { Authorization: `Bearer ${token}` } }),
]);
if (nodesRes.ok) {
const botsData: any = await nodesRes.json();
setBots(botsData);
} else {
let errorMsg = `Failed to fetch bots (${nodesRes.status})`;
try {
const errorData: ErrorResponse = await nodesRes.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || nodesRes.statusText}`;
} catch (e) { errorMsg += `: ${nodesRes.statusText}`; }
accumulatedError += errorMsg + '\n';
}
if (discordIdsRes.ok) {
const discordIdsData: DiscordId[] = await discordIdsRes.json();
setDiscordIds(discordIdsData);
} else {
let errorMsg = `Failed to fetch Discord IDs (${discordIdsRes.status})`;
try {
const errorData: ErrorResponse = await discordIdsRes.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || discordIdsRes.statusText}`;
} catch (e) { errorMsg += `: ${discordIdsRes.statusText}`; }
accumulatedError += errorMsg + '\n';
}
if (systemsRes.ok) {
const systemsData: System[] = await systemsRes.json();
setSystems(systemsData);
} else {
let errorMsg = `Failed to fetch systems (${systemsRes.status})`;
try {
const errorData: ErrorResponse = await systemsRes.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || systemsRes.statusText}`;
} catch (e) { errorMsg += `: ${systemsRes.statusText}`; }
accumulatedError += errorMsg + '\n';
}
if (accumulatedError) {
setError(accumulatedError.trim());
}
} catch (err: any) {
setError('Failed to fetch data. Check server connection or console.');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (token) {
fetchData();
}
}, [token]);
const handleAddId = async (): Promise<void> => {
setError('');
try {
const payload: Omit<DiscordId, '_id'> = {
...newIdData,
guild_ids: newIdData.guild_ids.split(',').map(id => id.trim()).filter(id => id),
};
const method = editingId ? 'PUT' : 'POST';
const url = editingId ? `${API_BASE_URL}/discord_ids/${editingId._id}` : `${API_BASE_URL}/discord_ids/`;
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
if (response.ok) {
fetchData();
setIsAddIdDialogOpen(false);
setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '' });
setEditingId(null);
} else {
let errorMsg = `Failed to ${editingId ? 'update' : 'add'} Discord ID (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError(`Network error during ${editingId ? 'update' : 'add'} Discord ID.`);
console.error(err);
}
};
const handleDeleteId = async (id: string): Promise<void> => {
if (!window.confirm('Are you sure you want to delete this Discord ID?')) return;
setError('');
try {
const response = await fetch(`${API_BASE_URL}/discord_ids/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
fetchData();
} else {
let errorMsg = `Failed to delete Discord ID (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError('Network error during delete Discord ID.');
console.error(err);
}
};
const handleAssignDismiss = async (): Promise<void> => {
setError('');
if (!selectedBotClientId || !selectedSystemId) {
setError("Bot Client ID and System must be selected.");
return;
}
try {
const endpoint = assignDismissAction === 'assign' ? 'assign' : 'dismiss';
const response = await fetch(`${API_BASE_URL}/systems/${selectedSystemId}/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ client_id: selectedBotClientId }),
});
if (response.ok) {
fetchData();
setIsAssignDismissDialogOpen(false);
setSelectedBotClientId('');
setSelectedSystemId('');
} else {
let errorMsg = `Failed to ${assignDismissAction} bot (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError(`Network error during ${assignDismissAction} bot.`);
console.error(err);
}
};
if (loading) return <p>Loading bots and Discord IDs...</p>;
if (error && !loading) return <p className="text-red-500 whitespace-pre-line">{error}</p>;
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Bots Management</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{error && <p className="text-red-500 whitespace-pre-line">{error}</p>}
<div>
<h3 className="text-lg font-semibold mb-2">Online Bots (Clients)</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Client ID</TableHead>
<TableHead>Assigned Discord ID (Name)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bots.length > 0 ? (
bots.map((botClientId) => {
const assignedSystem = systems.find(sys => sys.avail_on_nodes.includes(botClientId));
const assignedDiscordBot = discordIds.find(did => did.name === assignedSystem?.name);
return (
<TableRow key={botClientId}>
<TableCell className="font-medium">
<Link href={`/nodes/${botClientId}`} className="text-blue-600 hover:underline">
{botClientId}
</Link>
</TableCell>
<TableCell>
{assignedSystem ? `${assignedSystem.name} (System)` : "N/A or requires different mapping"}
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={2} className="text-center">No bots currently online or reported by /bots.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">Manage Discord IDs</h3>
<Button onClick={() => { setEditingId(null); setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '' }); setIsAddIdDialogOpen(true);}} className="mb-4">Add New Discord ID</Button>
<Button onClick={() => setIsAssignDismissDialogOpen(true)} className="mb-4 ml-2">Assign/Dismiss Bot System</Button>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Discord ID</TableHead>
<TableHead>Token (Partial)</TableHead>
<TableHead>Active</TableHead>
<TableHead>Guilds</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{discordIds.length > 0 ? (
discordIds.map((dId) => (
<TableRow key={dId._id}>
<TableCell className="font-medium">{dId.name}</TableCell>
<TableCell>{dId.discord_id}</TableCell>
<TableCell className="truncate max-w-xs">{dId.token ? dId.token.substring(0, 8) + '...' : 'N/A'}</TableCell>
<TableCell>{dId.active ? 'Yes' : 'No'}</TableCell>
<TableCell>{dId.guild_ids.join(', ')}</TableCell>
<TableCell>
<Button variant="outline" size="sm" className="mr-2"
onClick={() => {
setEditingId(dId);
setNewIdData({
discord_id: dId.discord_id,
name: dId.name,
token: dId.token,
active: dId.active,
guild_ids: dId.guild_ids.join(', '),
});
setIsAddIdDialogOpen(true);
}}>
Edit
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteId(dId._id)}>
Delete
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center">No Discord IDs found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Dialog open={isAddIdDialogOpen} onOpenChange={setIsAddIdDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{editingId ? 'Edit Discord ID' : 'Add New Discord ID'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">Name</Label>
<Input
id="name"
value={newIdData.name}
onChange={(e) => setNewIdData({ ...newIdData, name: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="discord_id" className="text-right">Discord ID</Label>
<Input
id="discord_id"
value={newIdData.discord_id}
onChange={(e) => setNewIdData({ ...newIdData, discord_id: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="token" className="text-right">Token</Label>
<Input
id="token"
value={newIdData.token}
onChange={(e) => setNewIdData({ ...newIdData, token: e.target.value })}
className="col-span-3"
placeholder={editingId ? "Token hidden for security, re-enter to change" : ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="guild_ids" className="text-right">Guild IDs (comma-separated)</Label>
<Input
id="guild_ids"
value={newIdData.guild_ids}
onChange={(e) => setNewIdData({ ...newIdData, guild_ids: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="active" className="text-right">Active</Label>
<Checkbox
id="active"
checked={newIdData.active}
onCheckedChange={(checked) => setNewIdData({ ...newIdData, active: checked === true })}
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleAddId}>{editingId ? 'Save Changes' : 'Add Discord ID'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isAssignDismissDialogOpen} onOpenChange={setIsAssignDismissDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Assign/Dismiss Bot to System</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="bot-client-id" className="text-right">Bot Client ID</Label>
<Select onValueChange={setSelectedBotClientId} value={selectedBotClientId}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select a bot client ID" />
</SelectTrigger>
<SelectContent>
{bots.map((botId) => (
<SelectItem key={botId} value={botId}>{botId}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="system-id" className="text-right">System</Label>
<Select onValueChange={setSelectedSystemId} value={selectedSystemId}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select a system" />
</SelectTrigger>
<SelectContent>
{systems.map((system) => (
<SelectItem key={system._id} value={system._id}>{system.name} ({system._id})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="action" className="text-right">Action</Label>
<Select
onValueChange={(value: string) => setAssignDismissAction(value as 'assign' | 'dismiss')}
value={assignDismissAction}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="assign">Assign</SelectItem>
<SelectItem value="dismiss">Dismiss</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button onClick={handleAssignDismiss} disabled={!selectedBotClientId || !selectedSystemId}>Perform Action</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
};
export default BotsManagement;

View File

@@ -0,0 +1,221 @@
// components/IndividualClientPage.tsx
"use client";
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { API_BASE_URL } from '@/constants/api';
import { ErrorResponse, NodeStatusResponse, System } from '@/types';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
interface IndividualClientPageProps {
clientId: string; // Now received as a prop
token: string;
}
const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, token }) => {
const [message, setMessage] = useState<string>('');
const [error, setError] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [currentClientDiscordStatus, setCurrentClientDiscordStatus] = useState<string>('Unknown');
const [currentClientOp25Status, setCurrentClientOp25Status] = useState<string>('Unknown');
// OP25 Set Config Popup States
const [selectedSystem, setSelectedSystem] = useState<string | null>(null);
const [isSetConfigDialogOpen, setIsSetConfigDialogOpen] = useState<boolean>(false);
const [availableSystems, setAvailableSystems] = useState<System[]>([]);
const fetchClientStatus = async () => {
try {
setLoading(true);
const response = await fetch(`${API_BASE_URL}/nodes/${clientId}/status`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data: NodeStatusResponse = await response.json();
console.log(data)
setCurrentClientDiscordStatus(data.status.discord_status);
setCurrentClientOp25Status(data.status.op25_status)
} else {
setCurrentClientDiscordStatus('Failed to fetch status');
setCurrentClientOp25Status('Failed to fetch status');
}
} catch (err) {
setCurrentClientDiscordStatus('Error fetching status');
setCurrentClientOp25Status('Error fetching status');
} finally {
setLoading(false);
}
};
const fetchSystems = async (): Promise<void> => {
setError('');
try {
const response = await fetch(`${API_BASE_URL}/systems/`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data: System[] = await response.json();
// Filter systems to those available on the current clientId
const filteredSystems = data.filter(system => system.avail_on_nodes.includes(clientId));
setAvailableSystems(filteredSystems);
} else {
let errorMsg = `Failed to fetch systems (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError('Failed to fetch systems. Check server connection or console.');
console.error(err);
}
};
useEffect(() => {
fetchClientStatus();
fetchSystems(); // Fetch systems when the component mounts or clientId/token changes
}, [clientId, token]);
const handleAction = async (action: 'join' | 'leave' | 'op25_start' | 'op25_stop' | 'op25_set'): Promise<void> => {
setMessage('');
setError('');
setLoading(true);
try {
const httpOptions: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}
}
if (action === "op25_set") {
if (!selectedSystem) {
setError("Please select a system to set configuration.");
setLoading(false);
return;
}
httpOptions.body = JSON.stringify({
"system_id": selectedSystem
});
}
const response = await fetch(`${API_BASE_URL}/nodes/${clientId}/${action}`, httpOptions);
const data = await response.json();
if (response.ok) {
setMessage(`Client "${clientId}" successfully ${action === 'op25_set' ? 'set config for system ' + selectedSystem : action + 'ed'}.`);
fetchClientStatus();
setIsSetConfigDialogOpen(false); // Close dialog on success
} else {
const errorData = data as ErrorResponse;
setError(`Failed to ${action} client "${clientId}": ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`);
}
} catch (err: any) {
setError(`Network error during ${action} client: ${err.message}`);
console.error(err);
} finally {
setLoading(false);
}
};
return (
<Card className="w-full max-w-md mx-auto mt-8">
<CardHeader>
<CardTitle>Manage Client: {clientId}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p>Current Discord Status: {currentClientDiscordStatus}</p>
<div className="flex space-x-2">
<Button
onClick={() => handleAction('join')}
disabled={loading}
className="flex-1"
>
{loading && message.includes('join') ? 'Joining...' : 'Join Client'}
</Button>
<Button
onClick={() => handleAction('leave')}
disabled={loading}
variant="outline"
className="flex-1"
>
{loading && message.includes('leave') ? 'Leaving...' : 'Leave Client'}
</Button>
</div>
<p>Current OP25 Status: {currentClientOp25Status}</p>
<div className="flex space-x-2">
<Button
onClick={() => handleAction('op25_start')}
disabled={loading}
className="flex-1"
>
{loading && message.includes('start') ? 'Starting...' : 'Start OP25'}
</Button>
<Button
onClick={() => setIsSetConfigDialogOpen(true)} // Open dialog for set config
disabled={loading}
className="flex-1"
>
Set OP25 Config
</Button>
<Button
onClick={() => handleAction('op25_stop')}
disabled={loading}
variant="outline"
className="flex-1"
>
{loading && message.includes('stop') ? 'Stopping...' : 'Stop OP25'}
</Button>
</div>
{message && <p className="text-green-500 text-sm">{message}</p>}
{error && <p className="text-red-500 text-sm">{error}</p>}
{loading && <p className="text-blue-500 text-sm">Processing request...</p>}
</CardContent>
{/* Set Config Dialog */}
<Dialog open={isSetConfigDialogOpen} onOpenChange={setIsSetConfigDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set OP25 System Configuration</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="system-select" className="text-right">Select System</Label>
<Select onValueChange={setSelectedSystem} value={selectedSystem || ''}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Choose a system" />
</SelectTrigger>
<SelectContent>
{availableSystems.map((system) => (
<SelectItem key={system._id} value={system._id}>
{system.name} ({system._id})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button onClick={() => handleAction('op25_set')} disabled={!selectedSystem || loading}>
{loading ? 'Setting...' : 'Confirm Set Config'}
</Button>
<Button variant="outline" onClick={() => setIsSetConfigDialogOpen(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};
export default IndividualClientPage;

View File

@@ -0,0 +1,67 @@
"use client";
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/context/AuthContext';
const LoginPage: React.FC = () => {
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [error, setError] = useState<string>('');
const { login } = useAuth();
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
setError('');
const success = await login(username, password);
if (!success) {
setError('Invalid username or password. Please try again.');
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
<Card className="w-full max-w-md p-6 space-y-4 rounded-lg shadow-lg">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">Login</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
required
className="mt-1"
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
required
className="mt-1"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<Button type="submit" className="w-full">
Login
</Button>
</form>
</CardContent>
</Card>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,354 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { API_BASE_URL } from '@/constants/api';
import { DemodTypes, TalkgroupTag, System, ErrorResponse } from '@/types'; // Import types
interface SystemsManagementProps {
token: string;
}
const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
const [systems, setSystems] = useState<System[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>('');
const [isAddSystemDialogOpen, setIsAddSystemDialogOpen] = useState<boolean>(false);
const [newSystemData, setNewSystemData] = useState<
Omit<System, '_id' | 'frequencies' | 'avail_on_nodes' | 'tags' | 'whitelist'> & {
_id?: string;
frequencies: string;
avail_on_nodes: string;
tags: string;
whitelist: string;
}
>({
type: DemodTypes.P25,
name: '',
frequencies: '',
location: '',
avail_on_nodes: '',
description: '',
tags: '[]',
whitelist: '',
});
const [editingSystem, setEditingSystem] = useState<System | null>(null);
const fetchSystems = async (): Promise<void> => {
setLoading(true);
setError('');
try {
const response = await fetch(`${API_BASE_URL}/systems/`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data: System[] = await response.json();
setSystems(data);
} else {
let errorMsg = `Failed to fetch systems (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError('Failed to fetch systems. Check server connection or console.');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (token) {
fetchSystems();
}
}, [token]);
const handleAddSystem = async (): Promise<void> => {
setError('');
try {
let parsedTags: TalkgroupTag[] | undefined = undefined;
try {
parsedTags = newSystemData.tags ? JSON.parse(newSystemData.tags) : [];
if (!Array.isArray(parsedTags)) throw new Error("Tags must be a JSON array.");
} catch (e: any) {
setError(`Invalid JSON format for Tags: ${e.message}`);
return;
}
const payload: Omit<System, '_id' | 'frequencies' | 'avail_on_nodes' | 'tags' | 'whitelist'> & {
_id?: string;
frequencies: number[];
avail_on_nodes: string[];
tags?: TalkgroupTag[];
whitelist?: number[];
} = {
name: newSystemData.name,
type: newSystemData.type,
location: newSystemData.location,
description: newSystemData.description || undefined,
frequencies: newSystemData.frequencies.split(',').map(f => parseInt(f.trim())).filter(f => !isNaN(f)),
avail_on_nodes: newSystemData.avail_on_nodes.split(',').map(node => node.trim()).filter(node => node),
tags: parsedTags,
whitelist: newSystemData.whitelist.split(',').map(w => parseInt(w.trim())).filter(w => !isNaN(w)),
};
if (editingSystem) {
payload._id = editingSystem._id;
} else if (newSystemData._id) {
payload._id = newSystemData._id;
}
const method = editingSystem ? 'PUT' : 'POST';
const url = editingSystem ? `${API_BASE_URL}/systems/${editingSystem._id}` :
(payload._id ? `${API_BASE_URL}/systems/${payload._id}` : `${API_BASE_URL}/systems/`);
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
if (response.ok) {
fetchSystems();
setIsAddSystemDialogOpen(false);
setNewSystemData({
_id: '', type: DemodTypes.P25, name: '', frequencies: '', location: '',
avail_on_nodes: '', description: '', tags: '[]', whitelist: '',
});
setEditingSystem(null);
} else {
let errorMsg = `Failed to ${editingSystem ? 'update' : 'add'} system (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError(`Network error during ${editingSystem ? 'update' : 'add'} system. ${err.message}`);
console.error(err);
}
};
const handleDeleteSystem = async (id: string): Promise<void> => {
if (!window.confirm('Are you sure you want to delete this system?')) return;
setError('');
try {
const response = await fetch(`${API_BASE_URL}/systems/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
fetchSystems();
} else {
let errorMsg = `Failed to delete system (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError('Network error during delete system.');
console.error(err);
}
};
if (loading) return <p>Loading systems...</p>;
if (error && !loading) return <p className="text-red-500 whitespace-pre-line">{error}</p>;
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Systems Management</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{error && <p className="text-red-500 whitespace-pre-line">{error}</p>}
<div>
<h3 className="text-lg font-semibold mb-2">All Systems</h3>
<Button onClick={() => {
setEditingSystem(null);
setNewSystemData({
_id: '', type: DemodTypes.P25, name: '', frequencies: '', location: '',
avail_on_nodes: '', description: '', tags: '[]', whitelist: '',
});
setIsAddSystemDialogOpen(true);
}} className="mb-4">Add New System</Button>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name (_id)</TableHead>
<TableHead>Type</TableHead>
<TableHead>Frequencies</TableHead>
<TableHead>Location</TableHead>
<TableHead>Available Nodes</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{systems.length > 0 ? (
systems.map((system: System) => (
<TableRow key={system._id}>
<TableCell className="font-medium">{system.name} <span className="text-xs text-gray-500">({system._id})</span></TableCell>
<TableCell>{system.type}</TableCell>
<TableCell>{system.frequencies.join(', ')}</TableCell>
<TableCell>{system.location}</TableCell>
<TableCell>{system.avail_on_nodes.join(', ')}</TableCell>
<TableCell>
<Button variant="outline" size="sm" className="mr-2"
onClick={() => {
setEditingSystem(system);
setNewSystemData({
_id: system._id,
type: system.type,
name: system.name,
frequencies: system.frequencies.join(', '),
location: system.location,
avail_on_nodes: system.avail_on_nodes.join(', '),
description: system.description || '',
tags: system.tags ? JSON.stringify(system.tags, null, 2) : '[]',
whitelist: system.whitelist ? system.whitelist.join(', ') : '',
});
setIsAddSystemDialogOpen(true);
}}>
Edit
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteSystem(system._id)}>
Delete
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center">No systems found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
<Dialog open={isAddSystemDialogOpen} onOpenChange={setIsAddSystemDialogOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{editingSystem ? 'Edit System' : 'Add New System'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4 max-h-[70vh] overflow-y-auto pr-2">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="system_id_input" className="text-right">ID (Optional for new)</Label>
<Input
id="system_id_input"
value={newSystemData._id || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, _id: e.target.value })}
className="col-span-3"
disabled={!!editingSystem}
placeholder={editingSystem ? newSystemData._id : "Leave blank to auto-generate, or specify"}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">Name</Label>
<Input
id="name"
value={newSystemData.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, name: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">Type</Label>
<Select onValueChange={(value: DemodTypes) => setNewSystemData({ ...newSystemData, type: value })} value={newSystemData.type}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DemodTypes.P25}>P25</SelectItem>
<SelectItem value={DemodTypes.DMR}>DMR</SelectItem>
<SelectItem value={DemodTypes.NBFM}>NBFM (Analog)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="frequencies" className="text-right">Frequencies (comma-separated)</Label>
<Input
id="frequencies"
value={newSystemData.frequencies}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, frequencies: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location" className="text-right">Location</Label>
<Input
id="location"
value={newSystemData.location}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, location: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="avail_on_nodes" className="text-right">Available Nodes (comma-separated)</Label>
<Input
id="avail_on_nodes"
value={newSystemData.avail_on_nodes}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, avail_on_nodes: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">Description</Label>
<Textarea
id="description"
value={newSystemData.description || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewSystemData({ ...newSystemData, description: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="tags" className="text-right">Tags (JSON string)</Label>
<Textarea
id="tags"
value={newSystemData.tags}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewSystemData({ ...newSystemData, tags: e.target.value })}
className="col-span-3"
placeholder='[{"talkgroup": "TG1", "tagDec": 123}, {"talkgroup": "TG2", "tagDec": 456}]'
rows={3}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="whitelist" className="text-right">Whitelist TGs (comma-separated)</Label>
<Input
id="whitelist"
value={newSystemData.whitelist}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, whitelist: e.target.value })}
className="col-span-3"
placeholder='123,456,789'
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleAddSystem}>{editingSystem ? 'Save Changes' : 'Add System'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};
export default SystemsManagement;

1
src/constants/api.ts Normal file
View File

@@ -0,0 +1 @@
export const API_BASE_URL = "http://172.16.100.81:5000";

View File

@@ -0,0 +1,89 @@
"use client";
import React, { useState, useEffect, createContext, useContext, ReactNode } from 'react';
import { API_BASE_URL } from '@/constants/api';
import { UserDetails, UserRoles, ErrorResponse, AuthContextType } from '@/types'; // Import types
export const AuthContext = createContext<AuthContextType | null>(null);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<UserDetails | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
const storedToken = localStorage.getItem('jwt_token');
const storedUser = localStorage.getItem('user_data');
if (storedToken && storedUser) {
setToken(storedToken);
try {
setUser(JSON.parse(storedUser));
} catch (error) {
console.error("Failed to parse stored user data:", error);
localStorage.removeItem('user_data');
}
}
setLoading(false);
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok) {
setToken(data.access_token);
const tempUser: UserDetails = { id: data.user_id || 'some-id', username: data.username, role: data.role || UserRoles.USER };
setUser(tempUser);
localStorage.setItem('jwt_token', data.access_token);
localStorage.setItem('user_data', JSON.stringify(tempUser));
return true;
} else {
const errorData = data as ErrorResponse;
console.error('Login failed:', errorData.message || errorData.detail || response.statusText);
return false;
}
} catch (error) {
console.error('Network error during login:', error);
return false;
}
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem('jwt_token');
localStorage.removeItem('user_data');
};
const hasPermission = (requiredRole: UserRoles): boolean => {
if (!user || !user.role) return false;
const roleOrder: Record<UserRoles, number> = {
[UserRoles.USER]: 0,
[UserRoles.MOD]: 1,
[UserRoles.ADMIN]: 2
};
return roleOrder[user.role] >= roleOrder[requiredRole];
};
return (
<AuthContext.Provider value={{ token, user, loading, login, logout, hasPermission }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === null) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

14
src/pages/_app.tsx Normal file
View File

@@ -0,0 +1,14 @@
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { AuthProvider } from '@/context/AuthContext';
import '@/app/globals.css'; // Assuming your global styles are here
function DRB({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
export default DRB;

View File

@@ -0,0 +1,64 @@
"use client";
import React from 'react';
import { useRouter } from 'next/router'; // For Pages Router
import { useAuth } from '@/context/AuthContext';
import IndividualClientPage from '@/components/IndividualClientPage';
import LoginPage from '@/components/LoginPage';
import { UserRoles } from '@/types';
import {Button} from '@/components/ui/button';
const ClientDetailPage: React.FC = () => {
const router = useRouter();
const { clientId } = router.query; // Get clientId from the URL
const { user, loading, token, hasPermission } = useAuth();
if (loading) {
return <div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">Loading Authentication...</div>;
}
if (!user || !token || !hasPermission(UserRoles.MOD)) {
// Redirect to login or show access denied if not authenticated or authorized
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans">
<header className="flex justify-between items-center p-4 bg-white dark:bg-gray-800 shadow-md">
<h1 className="text-xl font-bold">Radio App Admin</h1>
</header>
<main className="p-6">
{!user ? (
<LoginPage />
) : (
<div className="text-center text-red-500 text-lg">
You do not have permission to view this page. Your role: {user.role}. Required: {UserRoles.MOD}.
</div>
)}
</main>
</div>
);
}
// Ensure clientId is a string before passing
const clientIdentifier = Array.isArray(clientId) ? clientId[0] : clientId;
if (!clientIdentifier) {
return <div className="text-center text-red-500 text-lg mt-10">Client ID not found in URL.</div>;
}
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans">
<header className="flex justify-between items-center p-4 bg-white dark:bg-gray-800 shadow-md">
<h1 className="text-xl font-bold">Radio App Admin</h1>
<div className="flex items-center space-x-4">
<span className="text-sm">Logged in as: {user.username} ({user.role})</span>
<Button onClick={() => router.push('/')} variant="outline">Back to Management</Button> {/* Add a back button */}
<Button onClick={useAuth().logout} variant="outline">Logout</Button>
</div>
</header>
<main className="p-6">
<IndividualClientPage clientId={clientIdentifier} token={token} />
</main>
</div>
);
};
export default ClientDetailPage;

1
src/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
src/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
src/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
src/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
src/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

65
src/types/index.ts Normal file
View File

@@ -0,0 +1,65 @@
export enum DemodTypes {
P25 = "P25",
DMR = "DMR",
NBFM = "NBFM"
}
export interface TalkgroupTag {
talkgroup: string;
tagDec: number;
}
export interface DiscordId {
_id: string;
discord_id: string;
name: string;
token: string;
active: boolean;
guild_ids: string[];
}
export interface System {
_id: string;
type: DemodTypes;
name: string;
frequencies: number[];
location: string;
avail_on_nodes: string[];
description?: string;
tags?: TalkgroupTag[];
whitelist?: number[];
}
export enum UserRoles {
ADMIN = "admin",
MOD = "mod",
USER = "user"
}
export interface UserDetails {
id: string;
username: string;
role: UserRoles;
}
export interface ErrorResponse {
message?: string;
detail?: string | any;
}
export interface NodeStatusResponse {
status: {
op25_status: string;
discord_status: string;
}
}
// Auth Context Types
export interface AuthContextType {
token: string | null;
user: UserDetails | null;
loading: boolean;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
hasPermission: (requiredRole: UserRoles) => boolean;
}