Update UI to match consistent layout patterns between pages
- Refactor Add Stream page to match Teams page layout with glass panels - Rename "Add Stream" to "Streams" in navigation and page title - Add existing streams display with loading states and empty state - Implement unified design system with modern glass morphism styling - Add Header and Footer components with OBS status monitoring - Update global CSS with comprehensive component styling - Consolidate client components into main page files - Add real-time OBS connection status with 30-second polling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1d4b1eefba
commit
c28baa9e44
19 changed files with 2388 additions and 567 deletions
|
@ -1,144 +0,0 @@
|
|||
'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>
|
||||
);
|
||||
}
|
266
app/add/page.tsx
266
app/add/page.tsx
|
@ -1,10 +1,266 @@
|
|||
import AddStreamClient from './AddStreamClient';
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { Team } from '@/types';
|
||||
|
||||
interface Stream {
|
||||
id: number;
|
||||
name: string;
|
||||
obs_source_name: string;
|
||||
url: string;
|
||||
team_id: number;
|
||||
}
|
||||
|
||||
export default function AddStream() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
obs_source_name: '',
|
||||
url: '',
|
||||
team_id: null,
|
||||
});
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [streams, setStreams] = useState<Stream[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Fetch teams and streams on component mount
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [teamsResponse, streamsResponse] = await Promise.all([
|
||||
fetch('/api/teams'),
|
||||
fetch('/api/streams')
|
||||
]);
|
||||
|
||||
const teamsData = await teamsResponse.json();
|
||||
const streamsData = await streamsResponse.json();
|
||||
|
||||
// Map the API data to the format required by the Dropdown
|
||||
setTeams(
|
||||
teamsData.map((team: Team) => ({
|
||||
id: team.team_id,
|
||||
name: team.team_name,
|
||||
}))
|
||||
);
|
||||
|
||||
setStreams(streamsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
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
|
||||
fetchData(); // Refresh the streams list
|
||||
} else {
|
||||
setMessage(data.error || 'Something went wrong.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding stream:', error);
|
||||
setMessage('Failed to add stream.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Add a New Stream</h1>
|
||||
<AddStreamClient />
|
||||
<div className="container section">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="title">Streams</h1>
|
||||
<p className="subtitle">
|
||||
Organize your content by creating and managing stream sources
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add New Stream */}
|
||||
<div className="glass p-6 mb-6">
|
||||
<h2 className="card-title">Add Stream</h2>
|
||||
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Stream Name */}
|
||||
<div>
|
||||
<label className="block text-white font-semibold mb-3">
|
||||
Stream Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="input"
|
||||
placeholder="Enter a display name for the stream"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OBS Source Name */}
|
||||
<div>
|
||||
<label className="block text-white font-semibold mb-3">
|
||||
OBS Source Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="obs_source_name"
|
||||
value={formData.obs_source_name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="input"
|
||||
placeholder="Enter the exact source name from OBS"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className="block text-white font-semibold mb-3">
|
||||
Stream URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="url"
|
||||
value={formData.url}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="input"
|
||||
placeholder="https://example.com/stream"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team Selection */}
|
||||
<div>
|
||||
<label className="block text-white font-semibold mb-3">
|
||||
Team
|
||||
</label>
|
||||
<Dropdown
|
||||
options={teams}
|
||||
activeId={formData.team_id}
|
||||
onSelect={handleTeamSelect}
|
||||
label="Select a Team"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn w-full"
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{isSubmitting ? 'Adding Stream...' : 'Add Stream'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Success/Error Message */}
|
||||
{message && (
|
||||
<div className="glass p-6 mb-6">
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
message.includes('successfully')
|
||||
? 'bg-green-500/20 text-green-300 border-green-500/40'
|
||||
: 'bg-red-500/20 text-red-300 border-red-500/40'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${
|
||||
message.includes('successfully') ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}>
|
||||
{message.includes('successfully') ? (
|
||||
<svg className="icon-sm text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="icon-sm text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streams List */}
|
||||
<div className="glass p-6">
|
||||
<h2 className="card-title">Existing Streams</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center p-8">
|
||||
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<div className="text-white/60">Loading streams...</div>
|
||||
</div>
|
||||
) : streams.length === 0 ? (
|
||||
<div className="text-center p-8">
|
||||
<svg className="icon-lg mx-auto mb-4 text-white/40" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z" />
|
||||
</svg>
|
||||
<div className="text-white/60">No streams found</div>
|
||||
<div className="text-white/40 text-sm">Create your first stream above!</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{streams.map((stream) => {
|
||||
const team = teams.find(t => t.id === stream.team_id);
|
||||
return (
|
||||
<div key={stream.id} className="glass p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-green-500 to-blue-600 rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
||||
{stream.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-white">{stream.name}</div>
|
||||
<div className="text-sm text-white/60">OBS: {stream.obs_source_name}</div>
|
||||
<div className="text-sm text-white/60">Team: {team?.name || 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
||||
<a href={stream.url} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-400 hover:text-blue-300">
|
||||
View Stream
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
82
app/api/obsStatus/route.ts
Normal file
82
app/api/obsStatus/route.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const OBS_HOST = process.env.OBS_WEBSOCKET_HOST || '127.0.0.1';
|
||||
const OBS_PORT = process.env.OBS_WEBSOCKET_PORT || '4455';
|
||||
const OBS_PASSWORD = process.env.OBS_WEBSOCKET_PASSWORD || '';
|
||||
|
||||
// Use the persistent connection from obsClient
|
||||
const { getOBSClient, getConnectionStatus } = require('@/lib/obsClient');
|
||||
|
||||
const connectionStatus: {
|
||||
host: string;
|
||||
port: string;
|
||||
hasPassword: boolean;
|
||||
connected: boolean;
|
||||
version?: {
|
||||
obsVersion: string;
|
||||
obsWebSocketVersion: string;
|
||||
};
|
||||
currentScene?: string;
|
||||
sceneCount?: number;
|
||||
streaming?: boolean;
|
||||
recording?: boolean;
|
||||
error?: string;
|
||||
} = {
|
||||
host: OBS_HOST,
|
||||
port: OBS_PORT,
|
||||
hasPassword: !!OBS_PASSWORD,
|
||||
connected: false
|
||||
};
|
||||
|
||||
try {
|
||||
// Check current connection status first
|
||||
const currentStatus = getConnectionStatus();
|
||||
|
||||
let obs;
|
||||
if (currentStatus.connected) {
|
||||
// Use existing connection
|
||||
obs = currentStatus.client;
|
||||
} else {
|
||||
// Try to establish connection
|
||||
obs = await getOBSClient();
|
||||
}
|
||||
|
||||
// Get version info
|
||||
const versionInfo = await obs.call('GetVersion');
|
||||
|
||||
// Get current scene info
|
||||
const currentSceneInfo = await obs.call('GetCurrentProgramScene');
|
||||
|
||||
// Get scene list
|
||||
const sceneList = await obs.call('GetSceneList');
|
||||
|
||||
// Get streaming status
|
||||
const streamStatus = await obs.call('GetStreamStatus');
|
||||
|
||||
// Get recording status
|
||||
const recordStatus = await obs.call('GetRecordStatus');
|
||||
|
||||
connectionStatus.connected = true;
|
||||
connectionStatus.version = {
|
||||
obsVersion: versionInfo.obsVersion,
|
||||
obsWebSocketVersion: versionInfo.obsWebSocketVersion
|
||||
};
|
||||
connectionStatus.currentScene = currentSceneInfo.sceneName;
|
||||
connectionStatus.sceneCount = sceneList.scenes.length;
|
||||
connectionStatus.streaming = streamStatus.outputActive;
|
||||
connectionStatus.recording = recordStatus.outputActive;
|
||||
|
||||
} catch (err) {
|
||||
connectionStatus.error = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
}
|
||||
|
||||
return NextResponse.json(connectionStatus);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check OBS status', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
125
app/api/streams/[id]/route.ts
Normal file
125
app/api/streams/[id]/route.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../../lib/database';
|
||||
import { TABLE_NAMES } from '../../../../lib/constants';
|
||||
|
||||
// GET single stream
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const db = await getDatabase();
|
||||
const stream = await db.get(
|
||||
`SELECT * FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
||||
[resolvedParams.id]
|
||||
);
|
||||
|
||||
if (!stream) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stream not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(stream);
|
||||
} catch (error) {
|
||||
console.error('Error fetching stream:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch stream' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT update stream
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const { name, obs_source_name, url, team_id } = await request.json();
|
||||
|
||||
if (!name || !obs_source_name || !url) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name, OBS source name, and URL are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Check if stream exists
|
||||
const existingStream = await db.get(
|
||||
`SELECT * FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
||||
[resolvedParams.id]
|
||||
);
|
||||
|
||||
if (!existingStream) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stream not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update stream
|
||||
await db.run(
|
||||
`UPDATE ${TABLE_NAMES.STREAMS}
|
||||
SET name = ?, obs_source_name = ?, url = ?, team_id = ?
|
||||
WHERE id = ?`,
|
||||
[name, obs_source_name, url, team_id, resolvedParams.id]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Stream updated successfully',
|
||||
id: resolvedParams.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating stream:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update stream' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE stream
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Check if stream exists
|
||||
const existingStream = await db.get(
|
||||
`SELECT * FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
||||
[resolvedParams.id]
|
||||
);
|
||||
|
||||
if (!existingStream) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stream not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete stream
|
||||
await db.run(
|
||||
`DELETE FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
||||
[resolvedParams.id]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Stream deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting stream:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete stream' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
316
app/edit/[id]/page.tsx
Normal file
316
app/edit/[id]/page.tsx
Normal file
|
@ -0,0 +1,316 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { Team } from '@/types';
|
||||
|
||||
type Stream = {
|
||||
id: number;
|
||||
name: string;
|
||||
obs_source_name: string;
|
||||
url: string;
|
||||
team_id: number | null;
|
||||
};
|
||||
|
||||
export default function EditStream() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const streamId = params.id as string;
|
||||
|
||||
const [formData, setFormData] = useState<{
|
||||
name: string;
|
||||
obs_source_name: string;
|
||||
url: string;
|
||||
team_id: number | null;
|
||||
}>({
|
||||
name: '',
|
||||
obs_source_name: '',
|
||||
url: '',
|
||||
team_id: null,
|
||||
});
|
||||
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [stream, setStream] = useState<Stream | null>(null);
|
||||
|
||||
// Fetch stream data and teams
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [streamRes, teamsRes] = await Promise.all([
|
||||
fetch(`/api/streams/${streamId}`),
|
||||
fetch('/api/teams')
|
||||
]);
|
||||
|
||||
if (!streamRes.ok) {
|
||||
throw new Error('Stream not found');
|
||||
}
|
||||
|
||||
const [streamData, teamsData] = await Promise.all([
|
||||
streamRes.json(),
|
||||
teamsRes.json()
|
||||
]);
|
||||
|
||||
setStream(streamData);
|
||||
setFormData({
|
||||
name: streamData.name,
|
||||
obs_source_name: streamData.obs_source_name,
|
||||
url: streamData.url,
|
||||
team_id: streamData.team_id,
|
||||
});
|
||||
|
||||
// Map teams for dropdown
|
||||
setTeams(
|
||||
teamsData.map((team: Team) => ({
|
||||
id: team.team_id,
|
||||
name: team.team_name,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
setMessage('Failed to load stream data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (streamId) {
|
||||
fetchData();
|
||||
}
|
||||
}, [streamId]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleTeamSelect = (teamId: number) => {
|
||||
setFormData((prev) => ({ ...prev, team_id: teamId }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/streams/${streamId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
setMessage('Stream updated successfully!');
|
||||
// Redirect back to home after a short delay
|
||||
setTimeout(() => {
|
||||
router.push('/');
|
||||
}, 1500);
|
||||
} else {
|
||||
setMessage(data.error || 'Something went wrong.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating stream:', error);
|
||||
setMessage('Failed to update stream.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete this stream? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/streams/${streamId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
setMessage('Stream deleted successfully!');
|
||||
// Redirect back to home after a short delay
|
||||
setTimeout(() => {
|
||||
router.push('/');
|
||||
}, 1500);
|
||||
} else {
|
||||
setMessage(data.error || 'Failed to delete stream.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting stream:', error);
|
||||
setMessage('Failed to delete stream.');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container section">
|
||||
<div className="glass p-8 text-center">
|
||||
<div className="mb-4">Loading stream data...</div>
|
||||
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
return (
|
||||
<div className="container section">
|
||||
<div className="glass p-8 text-center">
|
||||
<h1 className="title">Stream Not Found</h1>
|
||||
<p className="subtitle">The requested stream could not be found.</p>
|
||||
<button onClick={() => router.push('/')} className="btn mt-4">
|
||||
Back to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container section">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="title">Edit Stream</h1>
|
||||
<p className="subtitle">
|
||||
Update the details for "{stream.name}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="glass p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Stream Name */}
|
||||
<div>
|
||||
<label className="block text-white font-semibold mb-3">
|
||||
Stream Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="input"
|
||||
placeholder="Enter a display name for the stream"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OBS Source Name */}
|
||||
<div>
|
||||
<label className="block text-white font-semibold mb-3">
|
||||
OBS Source Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="obs_source_name"
|
||||
value={formData.obs_source_name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="input"
|
||||
placeholder="Enter the exact source name from OBS"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className="block text-white font-semibold mb-3">
|
||||
Stream URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="url"
|
||||
value={formData.url}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="input"
|
||||
placeholder="https://example.com/stream"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team Selection */}
|
||||
<div>
|
||||
<label className="block text-white font-semibold mb-3">
|
||||
Team
|
||||
</label>
|
||||
<Dropdown
|
||||
options={teams}
|
||||
activeId={formData.team_id}
|
||||
onSelect={handleTeamSelect}
|
||||
label="Select a Team"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-6 space-y-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn w-full"
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
{isSubmitting ? 'Updating Stream...' : 'Update Stream'}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/')}
|
||||
className="btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="btn bg-red-600 hover:bg-red-700 flex-1"
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Delete Stream
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Success/Error Message */}
|
||||
{message && (
|
||||
<div className={`mt-6 p-4 rounded-lg border ${
|
||||
message.includes('successfully')
|
||||
? 'bg-green-500/20 text-green-300 border-green-500/40'
|
||||
: 'bg-red-500/20 text-red-300 border-red-500/40'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${
|
||||
message.includes('successfully') ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}>
|
||||
{message.includes('successfully') ? (
|
||||
<svg className="icon-sm text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="icon-sm text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
244
app/globals.css
244
app/globals.css
|
@ -2,22 +2,240 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Input styles removed - using explicit Tailwind classes on components instead */
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* Modern CSS Foundation */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #312e81 50%, #1e293b 100%);
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Glass Card Component */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Modern Button */
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
background: linear-gradient(135deg, #1d4ed8, #1e40af);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 25px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Input Styling */
|
||||
.input {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
color: white;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Dropdown Styling */
|
||||
.dropdown-button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
color: white;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-button:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
/* Icon Sizes */
|
||||
.icon-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.icon-md {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
/* Grid Layouts */
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-2,
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text Styles */
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.p-6 {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.p-8 {
|
||||
padding: 32px;
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import './globals.css';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
export const metadata = {
|
||||
title: 'OBS Source Switcher',
|
||||
|
@ -8,7 +10,11 @@ export const metadata = {
|
|||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
294
app/page.tsx
294
app/page.tsx
|
@ -11,71 +11,60 @@ type Stream = {
|
|||
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
|
||||
export default function Home() {
|
||||
const [streams, setStreams] = useState<Stream[]>([]);
|
||||
const [activeSources, setActiveSources] = useState<Record<ScreenType, string | null>>({
|
||||
large: null,
|
||||
left: null,
|
||||
right: null,
|
||||
topLeft: null,
|
||||
topRight: null,
|
||||
bottomLeft: null,
|
||||
bottomRight: null,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch available streams from the database
|
||||
async function fetchStreams() {
|
||||
setIsLoadingStreams(true);
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/streams');
|
||||
const data = await res.json();
|
||||
setStreams(data);
|
||||
// Fetch streams and active sources in parallel
|
||||
const [streamsRes, activeRes] = await Promise.all([
|
||||
fetch('/api/streams'),
|
||||
fetch('/api/getActive')
|
||||
]);
|
||||
|
||||
const [streamsData, activeData] = await Promise.all([
|
||||
streamsRes.json(),
|
||||
activeRes.json()
|
||||
]);
|
||||
|
||||
setStreams(streamsData);
|
||||
setActiveSources(activeData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching streams:', error);
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setIsLoadingStreams(false);
|
||||
setIsLoading(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();
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSetActive = async (screen: ScreenType, id: number | null) => {
|
||||
const handleSetActive = async (screen: ScreenType, id: number | null) => {
|
||||
const selectedStream = streams.find((stream) => stream.id === id);
|
||||
|
||||
// Update local state
|
||||
// Update local state immediately
|
||||
setActiveSources((prev) => ({
|
||||
...prev,
|
||||
[screen]: selectedStream?.obs_source_name || null,
|
||||
}));
|
||||
|
||||
// Update the backend
|
||||
try {
|
||||
if (id) {
|
||||
console.log('Setting screen ', screen);
|
||||
// Update backend
|
||||
if (id) {
|
||||
try {
|
||||
const response = await fetch('/api/setActive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
@ -85,123 +74,144 @@ const handleSetActive = async (screen: ScreenType, id: number | null) => {
|
|||
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);
|
||||
// Revert local state on error
|
||||
setActiveSources((prev) => ({
|
||||
...prev,
|
||||
[screen]: null,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting active stream:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDropdown = (screen: string) => {
|
||||
setOpenDropdown((prev) => (prev === screen ? null : screen)); // Toggle dropdown open/close
|
||||
setOpenDropdown((prev) => (prev === screen ? null : screen));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container section">
|
||||
<div className="glass p-8 text-center">
|
||||
<div className="mb-4">Loading streams...</div>
|
||||
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="container section">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="title">Stream Control Center</h1>
|
||||
<p className="subtitle">
|
||||
Manage your OBS sources across multiple screen positions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-5">
|
||||
<h1 className="text-2xl font-bold">Manage Streams</h1>
|
||||
{/* Main Screen */}
|
||||
<div className="glass p-6 mb-6">
|
||||
<h2 className="card-title">Primary Display</h2>
|
||||
<div className="max-w-md mx-auto">
|
||||
<Dropdown
|
||||
options={streams}
|
||||
activeId={
|
||||
streams.find((stream) => stream.obs_source_name === activeSources.large)?.id || null
|
||||
}
|
||||
onSelect={(id) => handleSetActive('large', id)}
|
||||
label="Select Primary Stream..."
|
||||
isOpen={openDropdown === 'large'}
|
||||
onToggle={() => handleToggleDropdown('large')}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
{/* Side Displays */}
|
||||
<div className="glass p-6 mb-6">
|
||||
<h2 className="card-title">Side Displays</h2>
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-center">Left Display</h3>
|
||||
<Dropdown
|
||||
options={streams}
|
||||
activeId={
|
||||
streams.find((stream) => stream.obs_source_name === activeSources.left)?.id || null
|
||||
}
|
||||
onSelect={(id) => handleSetActive('left', id)}
|
||||
label="Select Left Stream..."
|
||||
isOpen={openDropdown === 'left'}
|
||||
onToggle={() => handleToggleDropdown('left')}
|
||||
/>
|
||||
</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>
|
||||
<h3 className="text-lg font-semibold mb-4 text-center">Right Display</h3>
|
||||
<Dropdown
|
||||
options={streams}
|
||||
activeId={
|
||||
streams.find((stream) => stream.obs_source_name === activeSources.right)?.id || null
|
||||
}
|
||||
onSelect={(id) => handleSetActive('right', id)}
|
||||
label="Select Right Stream..."
|
||||
isOpen={openDropdown === 'right'}
|
||||
onToggle={() => handleToggleDropdown('right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2x2 Square for Additional Sources */}
|
||||
<div className="grid grid-cols-2 gap-4 p-5">
|
||||
{[
|
||||
{/* Corner Displays */}
|
||||
<div className="glass p-6">
|
||||
<h2 className="card-title">Corner Displays</h2>
|
||||
<div className="grid-4">
|
||||
{[
|
||||
{ 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)}
|
||||
/>
|
||||
].map(({ screen, label }) => (
|
||||
<div key={screen}>
|
||||
<h3 className="text-md font-semibold mb-3 text-center">{label}</h3>
|
||||
<Dropdown
|
||||
options={streams}
|
||||
activeId={
|
||||
streams.find((stream) => stream.obs_source_name === activeSources[screen])?.id || null
|
||||
}
|
||||
onSelect={(id) => handleSetActive(screen, id)}
|
||||
label="Select Stream..."
|
||||
isOpen={openDropdown === screen}
|
||||
onToggle={() => handleToggleDropdown(screen)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manage Streams Section */}
|
||||
{streams.length > 0 && (
|
||||
<div className="glass p-6 mt-6">
|
||||
<h2 className="card-title">Manage Streams</h2>
|
||||
<div className="grid gap-4">
|
||||
{streams.map((stream) => (
|
||||
<div key={stream.id} className="glass p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{stream.name}</h3>
|
||||
<p className="text-sm text-white/60">{stream.obs_source_name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/edit/${stream.id}`}
|
||||
className="btn-secondary"
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,204 +0,0 @@
|
|||
'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>
|
||||
);
|
||||
}
|
|
@ -1,10 +1,241 @@
|
|||
import TeamsClient from './TeamsClient';
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Team } from '@/types';
|
||||
|
||||
export default function Teams() {
|
||||
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('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
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;
|
||||
|
||||
setIsSubmitting(true);
|
||||
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');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-center mb-5">Team Management</h1>
|
||||
<TeamsClient />
|
||||
<div className="container section">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="title">Team Management</h1>
|
||||
<p className="subtitle">
|
||||
Organize your streams by creating and managing teams
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add New Team */}
|
||||
<div className="glass p-6 mb-6">
|
||||
<h2 className="card-title">Add New Team</h2>
|
||||
<form onSubmit={handleAddTeam} className="max-w-md mx-auto">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.target.value)}
|
||||
placeholder="Enter team name"
|
||||
className="input flex-1"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn"
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{isSubmitting ? 'Adding...' : 'Add Team'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Teams List */}
|
||||
<div className="glass p-6">
|
||||
<h2 className="card-title">Existing Teams</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center p-8">
|
||||
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<div className="text-white/60">Loading teams...</div>
|
||||
</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="text-center p-8">
|
||||
<svg className="icon-lg mx-auto mb-4 text-white/40" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
|
||||
</svg>
|
||||
<div className="text-white/60">No teams found</div>
|
||||
<div className="text-white/40 text-sm">Create your first team above!</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{teams.map((team) => (
|
||||
<div key={team.team_id} className="glass p-4">
|
||||
{editingTeam?.team_id === team.team_id ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
className="input flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleUpdateTeam(team.team_id)}
|
||||
className="btn"
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="btn-secondary"
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
||||
{team.team_name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-white">{team.team_name}</div>
|
||||
<div className="text-sm text-white/60">ID: {team.team_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => startEditing(team)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTeam(team.team_id)}
|
||||
className="btn bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -19,11 +19,12 @@ export default function Dropdown({
|
|||
isOpen: controlledIsOpen,
|
||||
onToggle,
|
||||
}: DropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(controlledIsOpen ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => { if (!dropdownRef.current || !(event.target instanceof Node)) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!dropdownRef.current || !(event.target instanceof Node)) return;
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
if (onToggle) onToggle(false);
|
||||
else setIsOpen(false);
|
||||
|
@ -53,19 +54,19 @@ const dropdownRef = useRef<HTMLDivElement>(null);
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block text-left" ref={dropdownRef}>
|
||||
<div className="relative w-full" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDropdown}
|
||||
className="inline-flex justify-between items-center w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
className="dropdown-button"
|
||||
>
|
||||
{activeOption ? activeOption.name : label}
|
||||
<span>
|
||||
{activeOption ? activeOption.name : label}
|
||||
</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-2 h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
className={`icon-sm transition-transform duration-200 ${(controlledIsOpen ?? isOpen) ? 'rotate-180' : ''}`}
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
@ -76,26 +77,24 @@ const dropdownRef = useRef<HTMLDivElement>(null);
|
|||
</button>
|
||||
|
||||
{(controlledIsOpen ?? isOpen) && (
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
style={{ zIndex: 50 }} // Set a high z-index value
|
||||
>
|
||||
<div className="py-1">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
<div className="absolute z-50 w-full dropdown-menu">
|
||||
{options.length === 0 ? (
|
||||
<div className="dropdown-item text-center">
|
||||
No streams available
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`block w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 ${
|
||||
activeOption?.id === option.id ? 'font-bold text-blue-500' : ''
|
||||
}`}
|
||||
className={`dropdown-item ${activeOption?.id === option.id ? 'active' : ''}`}
|
||||
>
|
||||
{option.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
148
components/Footer.tsx
Normal file
148
components/Footer.tsx
Normal file
|
@ -0,0 +1,148 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type OBSStatus = {
|
||||
host: string;
|
||||
port: string;
|
||||
hasPassword: boolean;
|
||||
connected: boolean;
|
||||
version?: {
|
||||
obsVersion: string;
|
||||
obsWebSocketVersion: string;
|
||||
};
|
||||
currentScene?: string;
|
||||
sceneCount?: number;
|
||||
streaming?: boolean;
|
||||
recording?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export default function Footer() {
|
||||
const [obsStatus, setObsStatus] = useState<OBSStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOBSStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/obsStatus');
|
||||
const data = await response.json();
|
||||
setObsStatus(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch OBS status:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOBSStatus();
|
||||
|
||||
// Refresh status every 30 seconds
|
||||
const interval = setInterval(fetchOBSStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<footer className="glass p-6 mt-8">
|
||||
<div className="container text-center">
|
||||
<div className="text-sm opacity-60">Loading OBS status...</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="glass p-6 mt-8">
|
||||
<div className="container">
|
||||
<div className="grid-2">
|
||||
{/* Connection Status */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`w-3 h-3 rounded-full ${obsStatus?.connected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<div>
|
||||
<h3 className="font-semibold">OBS Studio</h3>
|
||||
<p className="text-sm opacity-60">
|
||||
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{obsStatus && (
|
||||
<div className="text-sm opacity-80">
|
||||
<div>{obsStatus.host}:{obsStatus.port}</div>
|
||||
{obsStatus.hasPassword && <div>🔒 Authenticated</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Status */}
|
||||
{obsStatus?.connected && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Live Status</h3>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{obsStatus.currentScene && (
|
||||
<div className="flex justify-between">
|
||||
<span>Scene:</span>
|
||||
<span className="font-medium">{obsStatus.currentScene}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{obsStatus.sceneCount !== null && (
|
||||
<div className="flex justify-between">
|
||||
<span>Total Scenes:</span>
|
||||
<span className="font-medium">{obsStatus.sceneCount}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 mt-4">
|
||||
<div className={`flex items-center gap-2 ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}>
|
||||
<div className={`w-2 h-2 rounded-full ${obsStatus.streaming ? 'bg-red-500' : 'bg-gray-500'}`}></div>
|
||||
<span className="text-sm">{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-2 ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}>
|
||||
<div className={`w-2 h-2 rounded-full ${obsStatus.recording ? 'bg-red-500' : 'bg-gray-500'}`}></div>
|
||||
<span className="text-sm">{obsStatus.recording ? 'REC' : 'IDLE'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{obsStatus?.error && (
|
||||
<div className="mt-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="icon-sm text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm text-red-300">{obsStatus.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="mt-6 pt-4 border-t border-white/20 flex justify-between items-center text-sm opacity-60">
|
||||
<div className="flex gap-4">
|
||||
{obsStatus?.version && (
|
||||
<>
|
||||
<span>OBS v{obsStatus.version.obsVersion}</span>
|
||||
<span>WebSocket v{obsStatus.version.obsWebSocketVersion}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>OBS Stream Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
66
components/Header.tsx
Normal file
66
components/Header.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function Header() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (path: string) => pathname === path;
|
||||
|
||||
return (
|
||||
<header className="glass p-6 mb-8">
|
||||
<div className="container">
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<svg className="icon-md text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">OBS Stream Manager</h1>
|
||||
<p className="text-sm opacity-80">Professional Control</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex">
|
||||
<Link
|
||||
href="/"
|
||||
className={`btn ${isActive('/') ? 'active' : ''}`}
|
||||
style={{ marginRight: '12px' }}
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||
</svg>
|
||||
Home
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/add"
|
||||
className={`btn ${isActive('/add') ? 'active' : ''}`}
|
||||
style={{ marginRight: '12px' }}
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Streams
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/teams"
|
||||
className={`btn ${isActive('/teams') ? 'active' : ''}`}
|
||||
>
|
||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
|
||||
</svg>
|
||||
Teams
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -14,8 +14,8 @@ export interface TableConfig {
|
|||
// Default configuration
|
||||
export const DEFAULT_TABLE_CONFIG: TableConfig = {
|
||||
year: 2025,
|
||||
season: 'spring',
|
||||
suffix: 'adr'
|
||||
season: 'summer',
|
||||
suffix: 'sat'
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
104
lib/obsClient.js
104
lib/obsClient.js
|
@ -1,18 +1,59 @@
|
|||
// const config = require('../config');
|
||||
const { OBSWebSocket } = require('obs-websocket-js');
|
||||
|
||||
let obs = null;
|
||||
let isConnecting = false;
|
||||
let connectionPromise = null;
|
||||
|
||||
async function ensureConnected() {
|
||||
// If already connected, return the existing client
|
||||
if (obs && obs.identified) {
|
||||
return obs;
|
||||
}
|
||||
|
||||
// If already in the process of connecting, wait for it
|
||||
if (isConnecting && connectionPromise) {
|
||||
return connectionPromise;
|
||||
}
|
||||
|
||||
// Start new connection
|
||||
isConnecting = true;
|
||||
connectionPromise = connectToOBS();
|
||||
|
||||
try {
|
||||
await connectionPromise;
|
||||
return obs;
|
||||
} finally {
|
||||
isConnecting = false;
|
||||
connectionPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectToOBS() {
|
||||
const OBS_HOST = process.env.OBS_WEBSOCKET_HOST || '127.0.0.1';
|
||||
const OBS_PORT = process.env.OBS_WEBSOCKET_PORT || '4455';
|
||||
const OBS_PASSWORD = process.env.OBS_WEBSOCKET_PASSWORD || '';
|
||||
|
||||
// Create new client if needed
|
||||
if (!obs) {
|
||||
obs = new OBSWebSocket();
|
||||
|
||||
// Set up event handlers for connection management
|
||||
obs.on('ConnectionClosed', () => {
|
||||
console.log('OBS WebSocket connection closed');
|
||||
obs = null;
|
||||
});
|
||||
|
||||
obs.on('ConnectionError', (err) => {
|
||||
console.error('OBS WebSocket connection error:', err);
|
||||
obs = null;
|
||||
});
|
||||
|
||||
obs.on('Identified', () => {
|
||||
console.log('OBS WebSocket successfully identified');
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const OBS_HOST = process.env.OBS_WEBSOCKET_HOST || '127.0.0.1';
|
||||
const OBS_PORT = process.env.OBS_WEBSOCKET_PORT || '4455';
|
||||
const OBS_PASSWORD = process.env.OBS_WEBSOCKET_PASSWORD || '';
|
||||
|
||||
console.log('Connecting to OBS WebSocket...');
|
||||
console.log('Host:', OBS_HOST);
|
||||
console.log('Port:', OBS_PORT);
|
||||
|
@ -20,49 +61,51 @@ async function connectToOBS() {
|
|||
|
||||
await obs.connect(`ws://${OBS_HOST}:${OBS_PORT}`, OBS_PASSWORD);
|
||||
console.log('Connected to OBS WebSocket.');
|
||||
return obs;
|
||||
} catch (err) {
|
||||
console.error('Failed to connect to OBS WebSocket:', err.message);
|
||||
obs = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getOBSClient() {
|
||||
if (!obs) {
|
||||
throw new Error('OBS WebSocket client is not initialized. Call connectToOBS() first.');
|
||||
}
|
||||
// console.log('client', obs)
|
||||
return obs;
|
||||
async function getOBSClient() {
|
||||
return await ensureConnected();
|
||||
}
|
||||
|
||||
function getConnectionStatus() {
|
||||
return {
|
||||
connected: obs && obs.identified,
|
||||
client: obs
|
||||
};
|
||||
}
|
||||
|
||||
async function disconnectFromOBS() {
|
||||
if (obs) {
|
||||
await obs.disconnect();
|
||||
console.log('Disconnected from OBS WebSocket.');
|
||||
obs = null;
|
||||
try {
|
||||
await obs.disconnect();
|
||||
console.log('Disconnected from OBS WebSocket.');
|
||||
} catch (err) {
|
||||
console.error('Error disconnecting from OBS:', err.message);
|
||||
} finally {
|
||||
obs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addSourceToSwitcher(inputName, newSources) {
|
||||
if (!obs) {
|
||||
obs = new OBSWebSocket();
|
||||
}
|
||||
|
||||
try {
|
||||
const OBS_HOST = process.env.OBS_WEBSOCKET_HOST || '127.0.0.1';
|
||||
const OBS_PORT = process.env.OBS_WEBSOCKET_PORT || '4455';
|
||||
const OBS_PASSWORD = process.env.OBS_WEBSOCKET_PASSWORD || '';
|
||||
|
||||
await obs.connect(`ws://${OBS_HOST}:${OBS_PORT}`, OBS_PASSWORD);
|
||||
const obsClient = await getOBSClient();
|
||||
|
||||
// Step 1: Get current input settings
|
||||
const { inputSettings } = await obs.call('GetInputSettings', { inputName });
|
||||
const { inputSettings } = await obsClient.call('GetInputSettings', { inputName });
|
||||
// console.log('Current Settings:', inputSettings);
|
||||
|
||||
// Step 2: Add new sources to the sources array
|
||||
const updatedSources = [...inputSettings.sources, ...newSources];
|
||||
|
||||
// Step 3: Update the settings with the new sources array
|
||||
await obs.call('SetInputSettings', {
|
||||
await obsClient.call('SetInputSettings', {
|
||||
inputName,
|
||||
inputSettings: {
|
||||
...inputSettings,
|
||||
|
@ -71,9 +114,9 @@ async function addSourceToSwitcher(inputName, newSources) {
|
|||
});
|
||||
|
||||
console.log('Updated settings successfully for', inputName);
|
||||
obs.disconnect();
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -122,4 +165,11 @@ async function addSourceToSwitcher(inputName, newSources) {
|
|||
|
||||
|
||||
// Export all functions
|
||||
module.exports = { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher};
|
||||
module.exports = {
|
||||
connectToOBS,
|
||||
getOBSClient,
|
||||
disconnectFromOBS,
|
||||
addSourceToSwitcher,
|
||||
ensureConnected,
|
||||
getConnectionStatus
|
||||
};
|
520
package-lock.json
generated
520
package-lock.json
generated
|
@ -29,6 +29,7 @@
|
|||
"eslint-config-prettier": "^10.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
|
@ -88,6 +89,448 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.7.tgz",
|
||||
"integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.7.tgz",
|
||||
"integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.7.tgz",
|
||||
"integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.7.tgz",
|
||||
"integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.7.tgz",
|
||||
"integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.7.tgz",
|
||||
"integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.7.tgz",
|
||||
"integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.7.tgz",
|
||||
"integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.7.tgz",
|
||||
"integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.7.tgz",
|
||||
"integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.7.tgz",
|
||||
"integrity": "sha512-9Jex4uVpdeofiDxnwHRgen+j6398JlX4/6SCbbEFEXN7oMO2p0ueLN+e+9DdsdPLUdqns607HmzEFnxwr7+5wQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.7.tgz",
|
||||
"integrity": "sha512-TG1KJqjBlN9IHQjKVUYDB0/mUGgokfhhatlay8aZ/MSORMubEvj/J1CL8YGY4EBcln4z7rKFbsH+HeAv0d471w==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.7.tgz",
|
||||
"integrity": "sha512-Ty9Hj/lx7ikTnhOfaP7ipEm/ICcBv94i/6/WDg0OZ3BPBHhChsUbQancoWYSO0WNkEiSW5Do4febTTy4x1qYQQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.7.tgz",
|
||||
"integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.7.tgz",
|
||||
"integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.7.tgz",
|
||||
"integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.7.tgz",
|
||||
"integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.7.tgz",
|
||||
"integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.7.tgz",
|
||||
"integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.7.tgz",
|
||||
"integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.7.tgz",
|
||||
"integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.7.tgz",
|
||||
"integrity": "sha512-5VTtExUrWwHHEUZ/N+rPlHDwVFQ5aME7vRJES8+iQ0xC/bMYckfJ0l2n3yGIfRoXcK/wq4oXSItZAz5wslTKGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.7.tgz",
|
||||
"integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.7.tgz",
|
||||
"integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.7.tgz",
|
||||
"integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.7.tgz",
|
||||
"integrity": "sha512-FaPsAHTwm+1Gfvn37Eg3E5HIpfR3i6x1AIcla/MkqAIupD4BW3MrSeUqfoTzwwJhk3WE2/KqUn4/eenEJC76VA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||
|
@ -3233,6 +3676,48 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.7.tgz",
|
||||
"integrity": "sha512-daJB0q2dmTzo90L9NjRaohhRWrCzYxWNFTjEi72/h+p5DcY3yn4MacWfDakHmaBaDzDiuLJsCh0+6LK/iX+c+Q==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.7",
|
||||
"@esbuild/android-arm": "0.25.7",
|
||||
"@esbuild/android-arm64": "0.25.7",
|
||||
"@esbuild/android-x64": "0.25.7",
|
||||
"@esbuild/darwin-arm64": "0.25.7",
|
||||
"@esbuild/darwin-x64": "0.25.7",
|
||||
"@esbuild/freebsd-arm64": "0.25.7",
|
||||
"@esbuild/freebsd-x64": "0.25.7",
|
||||
"@esbuild/linux-arm": "0.25.7",
|
||||
"@esbuild/linux-arm64": "0.25.7",
|
||||
"@esbuild/linux-ia32": "0.25.7",
|
||||
"@esbuild/linux-loong64": "0.25.7",
|
||||
"@esbuild/linux-mips64el": "0.25.7",
|
||||
"@esbuild/linux-ppc64": "0.25.7",
|
||||
"@esbuild/linux-riscv64": "0.25.7",
|
||||
"@esbuild/linux-s390x": "0.25.7",
|
||||
"@esbuild/linux-x64": "0.25.7",
|
||||
"@esbuild/netbsd-arm64": "0.25.7",
|
||||
"@esbuild/netbsd-x64": "0.25.7",
|
||||
"@esbuild/openbsd-arm64": "0.25.7",
|
||||
"@esbuild/openbsd-x64": "0.25.7",
|
||||
"@esbuild/openharmony-arm64": "0.25.7",
|
||||
"@esbuild/sunos-x64": "0.25.7",
|
||||
"@esbuild/win32-arm64": "0.25.7",
|
||||
"@esbuild/win32-ia32": "0.25.7",
|
||||
"@esbuild/win32-x64": "0.25.7"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
|
@ -3899,6 +4384,21 @@
|
|||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
@ -7309,6 +7809,26 @@
|
|||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"create-sat-summer-2025-tables": "tsx scripts/createSatSummer2025Tables.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
|
@ -31,6 +32,7 @@
|
|||
"eslint-config-prettier": "^10.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
81
scripts/createSatSummer2025Tables.ts
Normal file
81
scripts/createSatSummer2025Tables.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { getTableName, BASE_TABLE_NAMES } from '../lib/constants';
|
||||
|
||||
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
||||
|
||||
const ensureDirectoryExists = (dirPath: string) => {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
console.log(`Created directory: ${dirPath}`);
|
||||
}
|
||||
};
|
||||
|
||||
const createSatSummer2025Tables = async () => {
|
||||
try {
|
||||
// Ensure the files directory exists
|
||||
ensureDirectoryExists(FILE_DIRECTORY);
|
||||
|
||||
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
|
||||
|
||||
// Open database connection
|
||||
const db = await open({
|
||||
filename: dbPath,
|
||||
driver: sqlite3.Database,
|
||||
});
|
||||
|
||||
console.log('Database connection established.');
|
||||
|
||||
// Generate table names for sat_summer_2025
|
||||
const streamsTableName = getTableName(BASE_TABLE_NAMES.STREAMS, {
|
||||
year: 2025,
|
||||
season: 'summer',
|
||||
suffix: 'sat'
|
||||
});
|
||||
|
||||
const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, {
|
||||
year: 2025,
|
||||
season: 'summer',
|
||||
suffix: 'sat'
|
||||
});
|
||||
|
||||
console.log(`Creating tables: ${streamsTableName} and ${teamsTableName}`);
|
||||
|
||||
// Create streams table
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ${streamsTableName} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
obs_source_name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
team_id INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
console.log(`✅ Created table: ${streamsTableName}`);
|
||||
|
||||
// Create teams table
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ${teamsTableName} (
|
||||
team_id INTEGER PRIMARY KEY,
|
||||
team_name TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
console.log(`✅ Created table: ${teamsTableName}`);
|
||||
|
||||
// Close database connection
|
||||
await db.close();
|
||||
console.log('Database connection closed.');
|
||||
console.log('✅ Successfully created sat_summer_2025 tables!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating tables:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Run the script
|
||||
createSatSummer2025Tables();
|
59
scripts/verifyTables.ts
Normal file
59
scripts/verifyTables.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import path from 'path';
|
||||
|
||||
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
||||
|
||||
const verifyTables = async () => {
|
||||
try {
|
||||
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
|
||||
|
||||
const db = await open({
|
||||
filename: dbPath,
|
||||
driver: sqlite3.Database,
|
||||
});
|
||||
|
||||
console.log('Checking all tables in the database...\n');
|
||||
|
||||
// Get all table names
|
||||
const tables = await db.all(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table'
|
||||
ORDER BY name
|
||||
`);
|
||||
|
||||
console.log('Tables found:');
|
||||
for (const table of tables) {
|
||||
console.log(`- ${table.name}`);
|
||||
}
|
||||
|
||||
// Check sat_summer_2025 tables specifically
|
||||
const satSummerTables = tables.filter(t =>
|
||||
t.name.includes('2025_summer_sat')
|
||||
);
|
||||
|
||||
if (satSummerTables.length > 0) {
|
||||
console.log('\n✅ sat_summer_2025 tables found:');
|
||||
for (const table of satSummerTables) {
|
||||
console.log(` - ${table.name}`);
|
||||
|
||||
// Get column info
|
||||
const columns = await db.all(`PRAGMA table_info(${table.name})`);
|
||||
console.log(' Columns:');
|
||||
for (const col of columns) {
|
||||
console.log(` - ${col.name} (${col.type})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('\n❌ No sat_summer_2025 tables found!');
|
||||
}
|
||||
|
||||
await db.close();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error verifying tables:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
verifyTables();
|
Loading…
Add table
Add a link
Reference in a new issue