Update UI to match consistent layout patterns between pages
Some checks failed
Lint and Build / build (20) (push) Has been cancelled
Lint and Build / build (22) (push) Has been cancelled

- 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:
Decobus 2025-07-19 04:39:40 -04:00
parent 1d4b1eefba
commit c28baa9e44
19 changed files with 2388 additions and 567 deletions

View file

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

View file

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

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

View 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
View 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 &quot;{stream.name}&quot;
</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>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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