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>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue