Add comprehensive studio mode support and stream organization
- Implement studio mode transition workflow with Go Live buttons - Add collapsible team grouping for better stream organization - Include source locking functionality for newly created streams - Enhance footer status indicators with improved visual styling - Create triggerTransition API endpoint for studio mode operations - Add CollapsibleGroup component for expandable content sections 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
07028b0792
commit
3bad71cb26
8 changed files with 603 additions and 116 deletions
|
@ -63,7 +63,7 @@ function generateOBSSourceName(teamSceneName: string, streamName: string): strin
|
|||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let name: string, url: string, team_id: number, obs_source_name: string;
|
||||
let name: string, url: string, team_id: number, obs_source_name: string, lockSources: boolean;
|
||||
|
||||
// Parse and validate request body
|
||||
try {
|
||||
|
@ -78,6 +78,7 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
({ name, url, team_id } = validation.data!);
|
||||
lockSources = body.lockSources !== false; // Default to true if not specified
|
||||
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 });
|
||||
|
@ -125,7 +126,7 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
if (!sourceExists) {
|
||||
// Create stream group with text overlay
|
||||
await createStreamGroup(groupName, name, teamInfo.team_name, url);
|
||||
await createStreamGroup(groupName, name, teamInfo.team_name, url, lockSources);
|
||||
|
||||
// Update team with group UUID if not set
|
||||
if (!teamInfo.group_uuid) {
|
||||
|
|
62
app/api/triggerTransition/route.ts
Normal file
62
app/api/triggerTransition/route.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getOBSClient } from '../../../lib/obsClient';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const obsClient = await getOBSClient();
|
||||
|
||||
// Check if studio mode is active
|
||||
const { studioModeEnabled } = await obsClient.call('GetStudioModeEnabled');
|
||||
|
||||
if (!studioModeEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Studio mode is not enabled',
|
||||
message: 'Studio mode must be enabled to trigger transitions'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Trigger the studio mode transition (preview to program)
|
||||
await obsClient.call('TriggerStudioModeTransition');
|
||||
console.log('Successfully triggered studio mode transition');
|
||||
|
||||
// Get the updated scene information after transition
|
||||
const [programResponse, previewResponse] = await Promise.all([
|
||||
obsClient.call('GetCurrentProgramScene'),
|
||||
obsClient.call('GetCurrentPreviewScene')
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
programScene: programResponse.currentProgramSceneName,
|
||||
previewScene: previewResponse.currentPreviewSceneName
|
||||
},
|
||||
message: 'Successfully transitioned preview to program'
|
||||
});
|
||||
} catch (obsError) {
|
||||
console.error('OBS WebSocket error during transition:', obsError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to trigger transition in OBS',
|
||||
details: obsError instanceof Error ? obsError.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error triggering transition:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to connect to OBS or trigger transition'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
|
@ -241,6 +241,28 @@ body {
|
|||
position: absolute;
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dropdown */
|
||||
.dropdown-menu::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar-track {
|
||||
background: rgba(7, 54, 66, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(88, 110, 117, 0.6);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(88, 110, 117, 0.8);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
|
@ -366,4 +388,79 @@ body {
|
|||
|
||||
.p-8 {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* Collapsible Group Styles */
|
||||
.collapsible-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.collapsible-header {
|
||||
width: 100%;
|
||||
background: rgba(7, 54, 66, 0.3);
|
||||
border: 1px solid rgba(88, 110, 117, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.collapsible-header:hover {
|
||||
background: rgba(7, 54, 66, 0.5);
|
||||
border-color: rgba(131, 148, 150, 0.4);
|
||||
}
|
||||
|
||||
.collapsible-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.collapsible-icon {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease;
|
||||
color: #93a1a1;
|
||||
}
|
||||
|
||||
.collapsible-icon.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.collapsible-title {
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fdf6e3;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.collapsible-count {
|
||||
background: rgba(38, 139, 210, 0.2);
|
||||
color: #268bd2;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.collapsible-content.open {
|
||||
max-height: 5000px;
|
||||
opacity: 1;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.collapsible-content-inner {
|
||||
padding: 20px;
|
||||
background: rgba(7, 54, 66, 0.2);
|
||||
border: 1px solid rgba(88, 110, 117, 0.2);
|
||||
border-radius: 12px;
|
||||
}
|
166
app/page.tsx
166
app/page.tsx
|
@ -158,6 +158,31 @@ export default function Home() {
|
|||
}
|
||||
}, [showSuccess, showError]);
|
||||
|
||||
const handleTransition = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/triggerTransition', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Update local state after successful transition
|
||||
setCurrentScene(result.data.programScene);
|
||||
setCurrentPreviewScene(result.data.previewScene);
|
||||
showSuccess('Transition Complete', 'Successfully transitioned preview to program');
|
||||
|
||||
// Refresh data to ensure UI is in sync
|
||||
fetchData();
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to trigger transition');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error triggering transition:', error);
|
||||
showError('Transition Failed', error instanceof Error ? error.message : 'Could not trigger transition. Please try again.');
|
||||
}
|
||||
}, [showSuccess, showError, fetchData]);
|
||||
|
||||
// Helper function to get scene button state and styling
|
||||
const getSceneButtonState = useCallback((sceneName: string) => {
|
||||
const isProgram = currentScene === sceneName;
|
||||
|
@ -168,25 +193,29 @@ export default function Home() {
|
|||
return {
|
||||
isActive: true,
|
||||
text: `Program & Preview: ${sceneName}`,
|
||||
background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))'
|
||||
background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))',
|
||||
showTransition: false
|
||||
};
|
||||
} else if (isProgram) {
|
||||
return {
|
||||
isActive: true,
|
||||
text: `Program: ${sceneName}`,
|
||||
background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))'
|
||||
background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))',
|
||||
showTransition: false
|
||||
};
|
||||
} else if (isPreview) {
|
||||
return {
|
||||
isActive: true,
|
||||
text: `Preview: ${sceneName}`,
|
||||
background: 'linear-gradient(135deg, var(--solarized-yellow), var(--solarized-orange))'
|
||||
background: 'linear-gradient(135deg, var(--solarized-yellow), var(--solarized-orange))',
|
||||
showTransition: true
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isActive: false,
|
||||
text: `Set Preview: ${sceneName}`,
|
||||
background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))'
|
||||
background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))',
|
||||
showTransition: false
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
@ -195,13 +224,15 @@ export default function Home() {
|
|||
return {
|
||||
isActive: true,
|
||||
text: `Active: ${sceneName}`,
|
||||
background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))'
|
||||
background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))',
|
||||
showTransition: false
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isActive: false,
|
||||
text: `Switch to ${sceneName}`,
|
||||
background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))'
|
||||
background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))',
|
||||
showTransition: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -251,18 +282,35 @@ export default function Home() {
|
|||
<div className="glass p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="card-title mb-0">Primary Display</h2>
|
||||
{(() => {
|
||||
const buttonState = getSceneButtonState('1-Screen');
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('1-Screen')}
|
||||
className={`btn ${buttonState.isActive ? 'active' : ''}`}
|
||||
style={{ background: buttonState.background }}
|
||||
>
|
||||
{buttonState.text}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<div className="flex">
|
||||
{(() => {
|
||||
const buttonState = getSceneButtonState('1-Screen');
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('1-Screen')}
|
||||
className={`btn ${buttonState.isActive ? 'active' : ''}`}
|
||||
style={{ background: buttonState.background }}
|
||||
>
|
||||
{buttonState.text}
|
||||
</button>
|
||||
{buttonState.showTransition && (
|
||||
<button
|
||||
onClick={handleTransition}
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--solarized-red), var(--solarized-magenta))',
|
||||
minWidth: '120px',
|
||||
marginLeft: '12px'
|
||||
}}
|
||||
>
|
||||
Go Live
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-md mx-auto">
|
||||
<Dropdown
|
||||
|
@ -280,18 +328,35 @@ export default function Home() {
|
|||
<div className="glass p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="card-title mb-0">Side Displays</h2>
|
||||
{(() => {
|
||||
const buttonState = getSceneButtonState('2-Screen');
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('2-Screen')}
|
||||
className={`btn ${buttonState.isActive ? 'active' : ''}`}
|
||||
style={{ background: buttonState.background }}
|
||||
>
|
||||
{buttonState.text}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<div className="flex">
|
||||
{(() => {
|
||||
const buttonState = getSceneButtonState('2-Screen');
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('2-Screen')}
|
||||
className={`btn ${buttonState.isActive ? 'active' : ''}`}
|
||||
style={{ background: buttonState.background }}
|
||||
>
|
||||
{buttonState.text}
|
||||
</button>
|
||||
{buttonState.showTransition && (
|
||||
<button
|
||||
onClick={handleTransition}
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--solarized-red), var(--solarized-magenta))',
|
||||
minWidth: '120px',
|
||||
marginLeft: '12px'
|
||||
}}
|
||||
>
|
||||
Go Live
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
|
@ -323,18 +388,35 @@ export default function Home() {
|
|||
<div className="glass p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="card-title mb-0">Corner Displays</h2>
|
||||
{(() => {
|
||||
const buttonState = getSceneButtonState('4-Screen');
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('4-Screen')}
|
||||
className={`btn ${buttonState.isActive ? 'active' : ''}`}
|
||||
style={{ background: buttonState.background }}
|
||||
>
|
||||
{buttonState.text}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<div className="flex">
|
||||
{(() => {
|
||||
const buttonState = getSceneButtonState('4-Screen');
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('4-Screen')}
|
||||
className={`btn ${buttonState.isActive ? 'active' : ''}`}
|
||||
style={{ background: buttonState.background }}
|
||||
>
|
||||
{buttonState.text}
|
||||
</button>
|
||||
{buttonState.showTransition && (
|
||||
<button
|
||||
onClick={handleTransition}
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--solarized-red), var(--solarized-magenta))',
|
||||
minWidth: '120px',
|
||||
marginLeft: '12px'
|
||||
}}
|
||||
>
|
||||
Go Live
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-4">
|
||||
{cornerDisplays.map(({ screen, label }) => (
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import CollapsibleGroup from '@/components/CollapsibleGroup';
|
||||
import { Team } from '@/types';
|
||||
import { useToast } from '@/lib/useToast';
|
||||
import { ToastContainer } from '@/components/Toast';
|
||||
|
@ -14,6 +15,174 @@ interface Stream {
|
|||
team_id: number;
|
||||
}
|
||||
|
||||
interface StreamsByTeamProps {
|
||||
streams: Stream[];
|
||||
teams: {id: number; name: string}[];
|
||||
onDelete: (stream: Stream) => void;
|
||||
}
|
||||
|
||||
function StreamsByTeam({ streams, teams, onDelete }: StreamsByTeamProps) {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
||||
const [useCustomExpanded, setUseCustomExpanded] = useState(false);
|
||||
|
||||
// Group streams by team
|
||||
const streamsByTeam = useMemo(() => {
|
||||
const grouped = new Map<number, Stream[]>();
|
||||
|
||||
// Initialize with all teams
|
||||
teams.forEach(team => {
|
||||
grouped.set(team.id, []);
|
||||
});
|
||||
|
||||
// Add "No Team" group for streams without a team
|
||||
grouped.set(-1, []);
|
||||
|
||||
// Group streams
|
||||
streams.forEach(stream => {
|
||||
const teamId = stream.team_id || -1;
|
||||
const teamStreams = grouped.get(teamId) || [];
|
||||
teamStreams.push(stream);
|
||||
grouped.set(teamId, teamStreams);
|
||||
});
|
||||
|
||||
// Only include groups that have streams
|
||||
const result: Array<{teamId: number; teamName: string; streams: Stream[]}> = [];
|
||||
|
||||
grouped.forEach((streamList, teamId) => {
|
||||
if (streamList.length > 0) {
|
||||
const team = teams.find(t => t.id === teamId);
|
||||
result.push({
|
||||
teamId,
|
||||
teamName: teamId === -1 ? 'No Team' : (team?.name || 'Unknown Team'),
|
||||
streams: streamList
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by team name, with "No Team" at the end
|
||||
result.sort((a, b) => {
|
||||
if (a.teamId === -1) return 1;
|
||||
if (b.teamId === -1) return -1;
|
||||
return a.teamName.localeCompare(b.teamName);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [streams, teams]);
|
||||
|
||||
const handleExpandAll = () => {
|
||||
const allIds = streamsByTeam.map(group => group.teamId);
|
||||
setExpandedGroups(new Set(allIds));
|
||||
setUseCustomExpanded(true);
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpandedGroups(new Set());
|
||||
setUseCustomExpanded(true);
|
||||
};
|
||||
|
||||
const handleToggleGroup = (teamId: number) => {
|
||||
const newExpanded = new Set(expandedGroups);
|
||||
if (newExpanded.has(teamId)) {
|
||||
newExpanded.delete(teamId);
|
||||
} else {
|
||||
newExpanded.add(teamId);
|
||||
}
|
||||
setExpandedGroups(newExpanded);
|
||||
setUseCustomExpanded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{streamsByTeam.length > 0 && (
|
||||
<div className="flex justify-end gap-2 mb-4">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleExpandAll}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
Expand All
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleCollapseAll}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{streamsByTeam.map(({ teamId, teamName, streams: teamStreams }) => (
|
||||
<CollapsibleGroup
|
||||
key={teamId}
|
||||
title={teamName}
|
||||
itemCount={teamStreams.length}
|
||||
defaultOpen={teamStreams.length <= 10}
|
||||
isOpen={useCustomExpanded ? expandedGroups.has(teamId) : undefined}
|
||||
onToggle={() => handleToggleGroup(teamId)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{teamStreams.map((stream) => (
|
||||
<div key={stream.id} className="glass p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="bg-gradient-to-br from-green-500 to-blue-600 rounded-lg flex items-center justify-center text-white font-bold flex-shrink-0"
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
fontSize: '24px',
|
||||
marginRight: '16px'
|
||||
}}
|
||||
>
|
||||
{stream.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-white">{stream.name}</div>
|
||||
<div className="text-sm text-white/60">OBS: {stream.obs_source_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-2">
|
||||
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={stream.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary text-sm"
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
View Stream
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onDelete(stream)}
|
||||
className="btn btn-danger text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleGroup>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddStream() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
|
@ -312,61 +481,11 @@ export default function AddStream() {
|
|||
<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 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="bg-gradient-to-br from-green-500 to-blue-600 rounded-lg flex items-center justify-center text-white font-bold flex-shrink-0"
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
fontSize: '24px',
|
||||
marginRight: '16px'
|
||||
}}
|
||||
>
|
||||
{stream.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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 space-y-2">
|
||||
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={stream.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary text-sm"
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
View Stream
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ id: stream.id, name: stream.name })}
|
||||
className="btn btn-danger text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<StreamsByTeam
|
||||
streams={streams}
|
||||
teams={teams}
|
||||
onDelete={(stream) => setDeleteConfirm({ id: stream.id, name: stream.name })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue