From c28baa9e4476dff171bcbdffc41d2eb31b8db0d1 Mon Sep 17 00:00:00 2001 From: Decobus Date: Sat, 19 Jul 2025 04:39:40 -0400 Subject: [PATCH] Update UI to match consistent layout patterns between pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/add/AddStreamClient.tsx | 144 -------- app/add/page.tsx | 266 +++++++++++++- app/api/obsStatus/route.ts | 82 +++++ app/api/streams/[id]/route.ts | 125 +++++++ app/edit/[id]/page.tsx | 316 ++++++++++++++++ app/globals.css | 244 ++++++++++++- app/layout.tsx | 8 +- app/page.tsx | 294 +++++++-------- app/teams/TeamsClient.tsx | 204 ----------- app/teams/page.tsx | 239 +++++++++++- components/Dropdown.tsx | 47 ++- components/Footer.tsx | 148 ++++++++ components/Header.tsx | 66 ++++ lib/constants.ts | 4 +- lib/obsClient.js | 104 ++++-- package-lock.json | 520 +++++++++++++++++++++++++++ package.json | 4 +- scripts/createSatSummer2025Tables.ts | 81 +++++ scripts/verifyTables.ts | 59 +++ 19 files changed, 2388 insertions(+), 567 deletions(-) delete mode 100644 app/add/AddStreamClient.tsx create mode 100644 app/api/obsStatus/route.ts create mode 100644 app/api/streams/[id]/route.ts create mode 100644 app/edit/[id]/page.tsx delete mode 100644 app/teams/TeamsClient.tsx create mode 100644 components/Footer.tsx create mode 100644 components/Header.tsx create mode 100644 scripts/createSatSummer2025Tables.ts create mode 100644 scripts/verifyTables.ts diff --git a/app/add/AddStreamClient.tsx b/app/add/AddStreamClient.tsx deleted file mode 100644 index 94507ab..0000000 --- a/app/add/AddStreamClient.tsx +++ /dev/null @@ -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) => { - 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 ( -
-

Add New Stream

-
-
- -
-
- -
-
- -
-
- - -
- -
- {message && ( -

- {message} -

- )} -
- ); -} diff --git a/app/add/page.tsx b/app/add/page.tsx index 1b23cdb..fd4d45b 100644 --- a/app/add/page.tsx +++ b/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([]); + 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) => { + 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 ( -
-

Add a New Stream

- +
+ {/* Title */} +
+

Streams

+

+ Organize your content by creating and managing stream sources +

+
+ + {/* Add New Stream */} +
+

Add Stream

+
+ {/* Stream Name */} +
+ + +
+ + {/* OBS Source Name */} +
+ + +
+ + {/* URL */} +
+ + +
+ + {/* Team Selection */} +
+ + +
+ + {/* Submit Button */} +
+ +
+
+
+ + {/* Success/Error Message */} + {message && ( +
+
+
+
+ {message.includes('successfully') ? ( + + + + ) : ( + + + + )} +
+ {message} +
+
+
+ )} + + {/* Streams List */} +
+

Existing Streams

+ + {isLoading ? ( +
+
+
Loading streams...
+
+ ) : streams.length === 0 ? ( +
+ + + +
No streams found
+
Create your first stream above!
+
+ ) : ( +
+ {streams.map((stream) => { + const team = teams.find(t => t.id === stream.team_id); + return ( +
+
+
+
+ {stream.name.charAt(0).toUpperCase()} +
+
+
{stream.name}
+
OBS: {stream.obs_source_name}
+
Team: {team?.name || 'Unknown'}
+
+
+
+
ID: {stream.id}
+ + View Stream + +
+
+
+ ); + })} +
+ )} +
); -} +} \ No newline at end of file diff --git a/app/api/obsStatus/route.ts b/app/api/obsStatus/route.ts new file mode 100644 index 0000000..13e8dc4 --- /dev/null +++ b/app/api/obsStatus/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/streams/[id]/route.ts b/app/api/streams/[id]/route.ts new file mode 100644 index 0000000..89cdb70 --- /dev/null +++ b/app/api/streams/[id]/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx new file mode 100644 index 0000000..2feb090 --- /dev/null +++ b/app/edit/[id]/page.tsx @@ -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(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) => { + 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 ( +
+
+
Loading stream data...
+
+
+
+ ); + } + + if (!stream) { + return ( +
+
+

Stream Not Found

+

The requested stream could not be found.

+ +
+
+ ); + } + + return ( +
+ {/* Title */} +
+

Edit Stream

+

+ Update the details for "{stream.name}" +

+
+ + {/* Form */} +
+
+
+ {/* Stream Name */} +
+ + +
+ + {/* OBS Source Name */} +
+ + +
+ + {/* URL */} +
+ + +
+ + {/* Team Selection */} +
+ + +
+ + {/* Action Buttons */} +
+ + +
+ + + +
+
+
+ + {/* Success/Error Message */} + {message && ( +
+
+
+ {message.includes('successfully') ? ( + + + + ) : ( + + + + )} +
+ {message} +
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index b3affc6..28e4099 100644 --- a/app/globals.css +++ b/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; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index f5687fa..2d6c46e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - {children} + +
+
{children}
+