Initial commit - OBS Source Switcher Plugin UI
Some checks failed
Lint and Build / build (20) (push) Has been cancelled
Lint and Build / build (22) (push) Has been cancelled

Complete Next.js application for managing OBS Source Switcher
- Stream management with multiple screen layouts
- Team management CRUD operations
- SQLite database integration
- OBS WebSocket API integration
- Updated to latest versions (Next.js 15.4.1, React 19.1.0, Tailwind CSS 4.0.0)
- Enhanced .gitignore for privacy and development
This commit is contained in:
Decobus 2025-07-15 22:15:57 -04:00
commit 1d4b1eefba
43 changed files with 9596 additions and 0 deletions

144
app/add/AddStreamClient.tsx Normal file
View file

@ -0,0 +1,144 @@
'use client';
import { useState, useEffect } from 'react';
import Dropdown from '../../components/Dropdown'; // Adjust the import path as needed
import { Team } from '@/types';
export default function AddStreamClient() {
const [formData, setFormData] = useState({
name: '',
obs_source_name: '',
url: '',
team_id: null, // Include team_id in the form data
});
const [teams, setTeams] = useState([]); // State to store teams
const [message, setMessage] = useState('');
// Fetch teams on component mount
useEffect(() => {
async function fetchTeams() {
try {
const response = await fetch('/api/teams');
const data = await response.json();
// Map the API data to the format required by the Dropdown
setTeams(
data.map((team:Team) => ({
id: team.team_id,
name: team.team_name,
}))
);
} catch (error) {
console.error('Failed to fetch teams:', error);
}
}
fetchTeams();
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleTeamSelect = (teamId:number) => {
// @ts-expect-error - team_id can be null or number in formData, but TypeScript expects only number
setFormData((prev) => ({ ...prev, team_id: teamId }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMessage('');
try {
const response = await fetch('/api/addStream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await response.json();
if (response.ok) {
setMessage(data.message);
setFormData({ name: '', obs_source_name: '', url: '', team_id: null }); // Reset form
} else {
setMessage(data.error || 'Something went wrong.');
}
} catch (error) {
console.error('Error adding stream:', error);
setMessage('Failed to add stream.');
}
};
return (
<div style={{ maxWidth: '600px', margin: '50px auto', padding: '20px', border: '1px solid #ccc' }}>
<h2>Add New Stream</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '10px' }}>
<label>
Name:
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
required
style={{ display: 'block', width: '100%', padding: '8px', margin: '5px 0' }}
/>
</label>
</div>
<div style={{ marginBottom: '10px' }}>
<label>
OBS Source Name:
<input
type="text"
name="obs_source_name"
value={formData.obs_source_name}
onChange={handleInputChange}
required
style={{ display: 'block', width: '100%', padding: '8px', margin: '5px 0' }}
/>
</label>
</div>
<div style={{ marginBottom: '10px' }}>
<label>
URL:
<input
type="url"
name="url"
value={formData.url}
onChange={handleInputChange}
required
style={{ display: 'block', width: '100%', padding: '8px', margin: '5px 0' }}
/>
</label>
</div>
<div style={{ marginBottom: '10px' }}>
<label>Team:</label>
<Dropdown
options={teams}
activeId={formData.team_id}
onSelect={handleTeamSelect}
label="Select a Team"
/>
</div>
<button
type="submit"
style={{
padding: '10px 20px',
background: '#0070f3',
color: '#fff',
border: 'none',
cursor: 'pointer',
}}
>
Add Stream
</button>
</form>
{message && (
<p style={{ marginTop: '20px', color: message.includes('successfully') ? 'green' : 'red' }}>
{message}
</p>
)}
</div>
);
}

10
app/add/page.tsx Normal file
View file

@ -0,0 +1,10 @@
import AddStreamClient from './AddStreamClient';
export default function AddStream() {
return (
<div>
<h1>Add a New Stream</h1>
<AddStreamClient />
</div>
);
}

196
app/api/addStream/route.ts Normal file
View file

@ -0,0 +1,196 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher } from '../../../lib/obsClient';
interface OBSClient {
call: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
}
interface OBSScene {
sceneName: string;
}
interface OBSInput {
inputName: string;
}
interface GetSceneListResponse {
currentProgramSceneName: string;
currentPreviewSceneName: string;
scenes: OBSScene[];
}
interface GetInputListResponse {
inputs: OBSInput[];
}
const screens = [
'ss_large',
'ss_left',
'ss_right',
'ss_top_left',
'ss_top_right',
'ss_bottom_left',
'ss_bottom_right',
];
async function fetchTeamName(teamId: number) {
try {
const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
const response = await fetch(`${baseUrl}/api/getTeamName?team_id=${teamId}`);
if (!response.ok) {
throw new Error('Failed to fetch team name');
}
const data = await response.json();
return data.team_name;
} catch (error) {
if (error instanceof Error) {
console.error('Error:', error.message);
} else {
console.error('An unknown error occurred:', error);
}
return null;
}
}
async function addBrowserSourceWithAudioControl(obs: OBSClient, sceneName: string, inputName: string, url: string) {
try {
// Step 1: Create the browser source input
await obs.call('CreateInput', {
sceneName,
inputName,
inputKind: 'browser_source',
inputSettings: {
width: 1600,
height: 900,
url,
},
});
console.log(`Browser source "${inputName}" created successfully.`);
// Step 2: Wait for the input to initialize
let inputReady = false;
for (let i = 0; i < 10; i++) {
try {
await obs.call('GetInputSettings', { inputName });
inputReady = true;
break;
} catch {
console.log(`Waiting for input "${inputName}" to initialize...`);
await new Promise((resolve) => setTimeout(resolve, 500)); // Wait 500ms before retrying
}
}
if (!inputReady) {
throw new Error(`Input "${inputName}" did not initialize in time.`);
}
// Step 3: Enable "Reroute audio"
await obs.call('SetInputSettings', {
inputName,
inputSettings: {
reroute_audio: true,
},
overlay: true, // Keep existing settings and apply changes
});
console.log(`Audio rerouted for "${inputName}".`);
// Step 4: Mute the input
await obs.call('SetInputMute', {
inputName,
inputMuted: true,
});
console.log(`Audio muted for "${inputName}".`);
} catch (error) {
if (error instanceof Error) {
console.error('Error adding browser source with audio control:', error.message);
} else {
console.error('An unknown error occurred while adding browser source:', error);
}
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, obs_source_name, url, team_id } = body;
if (!name || !obs_source_name || !url) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Connect to OBS WebSocket
console.log("Pre-connect")
await connectToOBS();
console.log('Pre client')
const obs: OBSClient = await getOBSClient();
// obs.on('message', (msg) => {
// console.log('Message from OBS:', msg);
// });
let inputs;
try {
const response = await obs.call('GetInputList');
const inputListResponse = response as unknown as GetInputListResponse;
inputs = inputListResponse.inputs;
// console.log('Inputs:', inputs);
} catch (err) {
if (err instanceof Error) {
console.error('Failed to fetch inputs:', err.message);
} else {
console.error('Failed to fetch inputs:', err);
}
throw new Error('GetInputList failed.');
}
const teamName = await fetchTeamName(team_id);
console.log('Team Name:', teamName)
const response = await obs.call('GetSceneList');
const sceneListResponse = response as unknown as GetSceneListResponse;
const { scenes } = sceneListResponse;
const groupExists = scenes.some((scene: OBSScene) => scene.sceneName === teamName);
if (!groupExists) {
await obs.call('CreateScene', { sceneName: teamName });
}
const sourceExists = inputs.some((input: OBSInput) => input.inputName === obs_source_name);
if (!sourceExists) {
await addBrowserSourceWithAudioControl(obs, teamName, obs_source_name, url)
console.log(`OBS source "${obs_source_name}" created.`);
for (const screen of screens) {
try {
await addSourceToSwitcher(screen, [
{ hidden: false, selected: false, value: obs_source_name },
]);
} catch (error) {
if (error instanceof Error) {
console.error(`Failed to add source to ${screen}:`, error.message);
} else {
console.error(`Failed to add source to ${screen}:`, error);
}
}
}
} else {
console.log(`OBS source "${obs_source_name}" already exists.`);
}
const db = await getDatabase();
const query = `INSERT INTO streams_2025_spring_adr (name, obs_source_name, url, team_id) VALUES (?, ?, ?, ?)`;
db.run(query, [name, obs_source_name, url, team_id])
await disconnectFromOBS();
return NextResponse.json({ message: 'Stream added successfully' }, {status: 201})
} catch (error) {
if (error instanceof Error) {
console.error('Error adding stream:', error.message);
} else {
console.error('An unknown error occurred while adding stream:', error);
}
await disconnectFromOBS();
return NextResponse.json({ error: 'Failed to add stream' }, { status: 500 });
}
}

View file

@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
// import config from '../../../config';
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files')
// Ensure directory exists
if (!fs.existsSync(FILE_DIRECTORY)) {
fs.mkdirSync(FILE_DIRECTORY, { recursive: true });
}
console.log('using', FILE_DIRECTORY)
export async function GET() {
try {
const largePath = path.join(FILE_DIRECTORY, 'large.txt');
const leftPath = path.join(FILE_DIRECTORY, 'left.txt');
const rightPath = path.join(FILE_DIRECTORY, 'right.txt');
const topLeftPath = path.join(FILE_DIRECTORY, 'topLeft.txt');
const topRightPath = path.join(FILE_DIRECTORY, 'topRight.txt');
const bottomLeftPath = path.join(FILE_DIRECTORY, 'bottomLeft.txt');
const bottomRightPath = path.join(FILE_DIRECTORY, 'bottomRight.txt');
const tankPath = path.join(FILE_DIRECTORY, 'tank.txt');
const treePath = path.join(FILE_DIRECTORY, 'tree.txt');
const kittyPath = path.join(FILE_DIRECTORY, 'kitty.txt');
const chickenPath = path.join(FILE_DIRECTORY, 'chicken.txt');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const large = fs.existsSync(largePath) ? fs.readFileSync(largePath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const left = fs.existsSync(leftPath) ? fs.readFileSync(leftPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const right = fs.existsSync(rightPath) ? fs.readFileSync(rightPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const topLeft = fs.existsSync(topLeftPath) ? fs.readFileSync(topLeftPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const topRight = fs.existsSync(topRightPath) ? fs.readFileSync(topRightPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const bottomLeft = fs.existsSync(bottomLeftPath) ? fs.readFileSync(bottomLeftPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const bottomRight = fs.existsSync(bottomRightPath) ? fs.readFileSync(bottomRightPath, 'utf-8') : null;
const tank = fs.existsSync(tankPath) ? fs.readFileSync(tankPath, 'utf-8') : null;
const tree = fs.existsSync(treePath) ? fs.readFileSync(treePath, 'utf-8') : null;
const kitty = fs.existsSync(kittyPath) ? fs.readFileSync(kittyPath, 'utf-8') : null;
const chicken = fs.existsSync(chickenPath) ? fs.readFileSync(chickenPath, 'utf-8') : null;
// For SaT
// return NextResponse.json({ large, left, right, topLeft, topRight, bottomLeft, bottomRight }, {status: 201})
return NextResponse.json({ tank, tree, kitty, chicken }, {status: 201})
} catch (error) {
console.error('Error reading active sources:', error);
return NextResponse.json({ error: 'Failed to read active sources' }, {status: 500});
}
}

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
export async function GET(request: NextRequest) {
try {
// Extract the team_id from the query string
const { searchParams } = new URL(request.url);
const teamId = searchParams.get('team_id');
if (!teamId) {
return NextResponse.json(
{ error: 'Missing team_id' },
{ status: 400 }
);
}
const db = await getDatabase();
const team = await db.get(
'SELECT team_name FROM teams_2025_spring_adr WHERE team_id = ?',
[teamId]
);
if (!team) {
return NextResponse.json(
{ error: 'Team not found' },
{ status: 404 }
);
}
return NextResponse.json({ team_name: team.team_name });
} catch (error) {
console.error('Error fetching team name:', error instanceof Error ? error.message : String(error));
return NextResponse.json(
{ error: 'Failed to fetch team name' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { FILE_DIRECTORY } from '../../../config';
import { getDatabase } from '../../../lib/database';
import { Stream, Screen } from '@/types';
export async function POST(request: NextRequest) {
const body: Screen = await request.json();
const { screen, id } = body;
const validScreens = ['large', 'left', 'right', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight'];
if (!validScreens.includes(screen)) {
return NextResponse.json({ error: 'Invalid screen name' }, { status: 400 });
}
console.log('Writing files to', path.join(FILE_DIRECTORY(), `${screen}.txt`));
const filePath = path.join(FILE_DIRECTORY(), `${screen}.txt`);
try {
const db = await getDatabase();
const stream: Stream | undefined = await db.get<Stream>(
'SELECT * FROM streams_2025_spring_adr WHERE id = ?',
[id]
);
console.log('Stream:', stream);
if (!stream) {
return NextResponse.json({ error: 'Stream not found' }, { status: 400 });
}
fs.writeFileSync(filePath, stream.obs_source_name);
return NextResponse.json({ message: `${screen} updated successfully.` }, { status: 200 });
} catch (error) {
console.error('Error updating active source:', error);
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
return NextResponse.json(
{ error: 'Failed to update active source', details: errorMessage },
{ status: 500 }
);
}
}

18
app/api/streams/route.ts Normal file
View file

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { Stream } from '@/types';
import { TABLE_NAMES } from '../../../lib/constants';
export async function GET() {
try {
const db = await getDatabase();
const streams: Stream[] = await db.all(`SELECT * FROM ${TABLE_NAMES.STREAMS}`);
return NextResponse.json(streams);
} catch (error) {
console.error('Error fetching streams:', error);
return NextResponse.json(
{ error: 'Failed to fetch streams' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '@/lib/database';
import { TABLE_NAMES } from '@/lib/constants';
export async function PUT(
request: Request,
{ params }: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId: teamIdParam } = await params;
const teamId = parseInt(teamIdParam);
const { team_name } = await request.json();
if (!team_name) {
return NextResponse.json({ error: 'Team name is required' }, { status: 400 });
}
const db = await getDatabase();
const result = await db.run(
`UPDATE ${TABLE_NAMES.TEAMS} SET team_name = ? WHERE team_id = ?`,
[team_name, teamId]
);
if (result.changes === 0) {
return NextResponse.json({ error: 'Team not found' }, { status: 404 });
}
return NextResponse.json({ message: 'Team updated successfully' });
} catch (error) {
console.error('Error updating team:', error);
return NextResponse.json({ error: 'Failed to update team' }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId: teamIdParam } = await params;
const teamId = parseInt(teamIdParam);
const db = await getDatabase();
await db.run('BEGIN TRANSACTION');
try {
await db.run(
`DELETE FROM ${TABLE_NAMES.STREAMS} WHERE team_id = ?`,
[teamId]
);
const result = await db.run(
`DELETE FROM ${TABLE_NAMES.TEAMS} WHERE team_id = ?`,
[teamId]
);
if (result.changes === 0) {
await db.run('ROLLBACK');
return NextResponse.json({ error: 'Team not found' }, { status: 404 });
}
await db.run('COMMIT');
return NextResponse.json({ message: 'Team and associated streams deleted successfully' });
} catch (error) {
await db.run('ROLLBACK');
throw error;
}
} catch (error) {
console.error('Error deleting team:', error);
return NextResponse.json({ error: 'Failed to delete team' }, { status: 500 });
}
}

37
app/api/teams/route.ts Normal file
View file

@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { Team } from '@/types';
import { TABLE_NAMES } from '@/lib/constants';
export async function GET() {
const db = await getDatabase();
const teams: Team[] = await db.all(`SELECT * FROM ${TABLE_NAMES.TEAMS}`);
return NextResponse.json(teams);
}
export async function POST(request: Request) {
try {
const { team_name } = await request.json();
if (!team_name) {
return NextResponse.json({ error: 'Team name is required' }, { status: 400 });
}
const db = await getDatabase();
const result = await db.run(
`INSERT INTO ${TABLE_NAMES.TEAMS} (team_name) VALUES (?)`,
[team_name]
);
const newTeam: Team = {
team_id: result.lastID!,
team_name: team_name
};
return NextResponse.json(newTeam, { status: 201 });
} catch (error) {
console.error('Error creating team:', error);
return NextResponse.json({ error: 'Failed to create team' }, { status: 500 });
}
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
app/fonts/GeistMonoVF.woff Normal file

Binary file not shown.

BIN
app/fonts/GeistVF.woff Normal file

Binary file not shown.

23
app/globals.css Normal file
View file

@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Input styles removed - using explicit Tailwind classes on components instead */
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

14
app/layout.tsx Normal file
View file

@ -0,0 +1,14 @@
import './globals.css';
export const metadata = {
title: 'OBS Source Switcher',
description: 'A tool to manage OBS sources dynamically',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

207
app/page.tsx Normal file
View file

@ -0,0 +1,207 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Dropdown from '@/components/Dropdown';
type Stream = {
id: number;
name: string;
obs_source_name: string;
url: string;
};
export default function Home() {
const [streams, setStreams] = useState<Stream[]>([]);
type ScreenType = 'large' | 'left' | 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
const [activeSources, setActiveSources] = useState<Record<ScreenType, string | null>>({
large: null,
left: null,
right: null,
topLeft: null,
topRight: null,
bottomLeft: null,
bottomRight: null,
});
const [isLoadingStreams, setIsLoadingStreams] = useState(true);
const [isLoadingActiveSources, setIsLoadingActiveSources] = useState(true);
const [openDropdown, setOpenDropdown] = useState<string | null>(null); // Manage open dropdown
useEffect(() => {
// Fetch available streams from the database
async function fetchStreams() {
setIsLoadingStreams(true);
try {
const res = await fetch('/api/streams');
const data = await res.json();
setStreams(data);
} catch (error) {
console.error('Error fetching streams:', error);
} finally {
setIsLoadingStreams(false);
}
}
// Fetch current active sources from files
async function fetchActiveSources() {
setIsLoadingActiveSources(true);
try {
const res = await fetch('/api/getActive');
const data = await res.json();
console.log('Fetched activeSources:', data); // Debug log
setActiveSources(data);
} catch (error) {
console.error('Error fetching active sources:', error);
} finally {
setIsLoadingActiveSources(false);
}
}
fetchStreams();
fetchActiveSources();
}, []);
const handleSetActive = async (screen: ScreenType, id: number | null) => {
const selectedStream = streams.find((stream) => stream.id === id);
// Update local state
setActiveSources((prev) => ({
...prev,
[screen]: selectedStream?.obs_source_name || null,
}));
// Update the backend
try {
if (id) {
console.log('Setting screen ', screen);
const response = await fetch('/api/setActive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ screen, id }),
});
if (!response.ok) {
throw new Error('Failed to set active stream');
}
const data = await response.json();
console.log(data.message);
}
} catch (error) {
console.error('Error setting active stream:', error);
}
};
const handleToggleDropdown = (screen: string) => {
setOpenDropdown((prev) => (prev === screen ? null : screen)); // Toggle dropdown open/close
};
return (
<div>
{/* Navigation Links */}
<div className="text-center mb-5 space-x-4">
<Link
href="/add"
className="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"
>
Add New Stream
</Link>
<span className="text-gray-400">|</span>
<Link
href="/teams"
className="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"
>
Manage Teams
</Link>
</div>
<div className="text-center mb-5">
<h1 className="text-2xl font-bold">Manage Streams</h1>
</div>
{/* Display loading indicator if either streams or active sources are loading */}
{isLoadingStreams || isLoadingActiveSources ? (
<div className="text-center text-gray-500">Loading...</div>
) : (
<>
{/* Large Screen on its own line */}
<div className="flex justify-center p-5">
<div className="text-center border border-gray-400 p-4 rounded-lg shadow w-full max-w-md">
<h2 className="text-lg font-semibold mb-2">Large</h2>
<Dropdown
options={streams}
activeId={
streams.find((stream) => stream.obs_source_name === activeSources.large)?.id || null
}
onSelect={(id) => handleSetActive('large', id)}
label="Select a Stream..."
isOpen={openDropdown === 'large'}
onToggle={() => handleToggleDropdown('large')}
/>
</div>
</div>
{/* Row for Left and Right Screens */}
<div className="flex justify-around p-5">
{/* Left Screen */}
<div className="flex-1 text-center border border-gray-400 p-4 rounded-lg shadow mx-2">
<h2 className="text-lg font-semibold mb-2">Left</h2>
<Dropdown
options={streams}
activeId={
streams.find((stream) => stream.obs_source_name === activeSources.left)?.id || null
}
onSelect={(id) => handleSetActive('left', id)}
label="Select a Stream..."
isOpen={openDropdown === 'left'}
onToggle={() => handleToggleDropdown('left')}
/>
</div>
{/* Right Screen */}
<div className="flex-1 text-center border border-gray-400 p-4 rounded-lg shadow mx-2">
<h2 className="text-lg font-semibold mb-2">Right</h2>
<Dropdown
options={streams}
activeId={
streams.find((stream) => stream.obs_source_name === activeSources.right)?.id || null
}
onSelect={(id) => handleSetActive('right', id)}
label="Select a Stream..."
isOpen={openDropdown === 'right'}
onToggle={() => handleToggleDropdown('right')}
/>
</div>
</div>
{/* 2x2 Square for Additional Sources */}
<div className="grid grid-cols-2 gap-4 p-5">
{[
{ screen: 'topLeft' as const, label: 'Top Left' },
{ screen: 'topRight' as const, label: 'Top Right' },
{ screen: 'bottomLeft' as const, label: 'Bottom Left' },
{ screen: 'bottomRight' as const, label: 'Bottom Right' },
].map(({ screen, label }) => (
<div key={screen} className="text-center border border-gray-400 p-4 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-2">{label}</h2>
<Dropdown
options={streams}
activeId={
streams.find((stream) => stream.obs_source_name === activeSources[screen])?.id ||
null
}
onSelect={(id) => handleSetActive(screen, id)}
label="Select a Stream..."
isOpen={openDropdown === screen}
onToggle={() => handleToggleDropdown(screen)}
/>
</div>
))}
</div>
</>
)}
</div>
);
}

204
app/teams/TeamsClient.tsx Normal file
View file

@ -0,0 +1,204 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Team } from '@/types';
export default function TeamsClient() {
const [teams, setTeams] = useState<Team[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [newTeamName, setNewTeamName] = useState('');
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
const [editingName, setEditingName] = useState('');
useEffect(() => {
fetchTeams();
}, []);
const fetchTeams = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/teams');
const data = await res.json();
setTeams(data);
} catch (error) {
console.error('Error fetching teams:', error);
} finally {
setIsLoading(false);
}
};
const handleAddTeam = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTeamName.trim()) return;
try {
const res = await fetch('/api/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ team_name: newTeamName }),
});
if (res.ok) {
setNewTeamName('');
fetchTeams();
} else {
const error = await res.json();
alert(`Error adding team: ${error.error}`);
}
} catch (error) {
console.error('Error adding team:', error);
alert('Failed to add team');
}
};
const handleUpdateTeam = async (teamId: number) => {
if (!editingName.trim()) return;
try {
const res = await fetch(`/api/teams/${teamId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ team_name: editingName }),
});
if (res.ok) {
setEditingTeam(null);
setEditingName('');
fetchTeams();
} else {
const error = await res.json();
alert(`Error updating team: ${error.error}`);
}
} catch (error) {
console.error('Error updating team:', error);
alert('Failed to update team');
}
};
const handleDeleteTeam = async (teamId: number) => {
if (!confirm('Are you sure you want to delete this team? This will also delete all associated streams.')) {
return;
}
try {
const res = await fetch(`/api/teams/${teamId}`, {
method: 'DELETE',
});
if (res.ok) {
fetchTeams();
} else {
const error = await res.json();
alert(`Error deleting team: ${error.error}`);
}
} catch (error) {
console.error('Error deleting team:', error);
alert('Failed to delete team');
}
};
const startEditing = (team: Team) => {
setEditingTeam(team);
setEditingName(team.team_name);
};
const cancelEditing = () => {
setEditingTeam(null);
setEditingName('');
};
return (
<div className="max-w-4xl mx-auto p-5">
<div className="text-center mb-5">
<Link
href="/"
className="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"
>
Back to Stream Management
</Link>
</div>
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Add New Team</h2>
<form onSubmit={handleAddTeam} className="flex gap-3">
<input
type="text"
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder="Team name"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Add Team
</button>
</form>
</div>
<div className="bg-white rounded-lg shadow">
<h2 className="text-xl font-semibold p-6 border-b">Existing Teams</h2>
{isLoading ? (
<div className="p-6 text-center text-gray-500">Loading...</div>
) : teams.length === 0 ? (
<div className="p-6 text-center text-gray-500">No teams found. Add one above!</div>
) : (
<div className="divide-y">
{teams.map((team) => (
<div key={team.team_id} className="p-6 flex items-center justify-between">
{editingTeam?.team_id === team.team_id ? (
<>
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mr-3"
/>
<div className="flex gap-2">
<button
onClick={() => handleUpdateTeam(team.team_id)}
className="px-3 py-1 bg-green-600 text-white rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
>
Save
</button>
<button
onClick={cancelEditing}
className="px-3 py-1 bg-gray-600 text-white rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Cancel
</button>
</div>
</>
) : (
<>
<div>
<span className="font-medium">{team.team_name}</span>
<span className="text-gray-500 text-sm ml-2">(ID: {team.team_id})</span>
</div>
<div className="flex gap-2">
<button
onClick={() => startEditing(team)}
className="px-3 py-1 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Edit
</button>
<button
onClick={() => handleDeleteTeam(team.team_id)}
className="px-3 py-1 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Delete
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

10
app/teams/page.tsx Normal file
View file

@ -0,0 +1,10 @@
import TeamsClient from './TeamsClient';
export default function Teams() {
return (
<div>
<h1 className="text-2xl font-bold text-center mb-5">Team Management</h1>
<TeamsClient />
</div>
);
}