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

@ -19,11 +19,12 @@ export default function Dropdown({
isOpen: controlledIsOpen,
onToggle,
}: DropdownProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(controlledIsOpen ?? false);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { if (!dropdownRef.current || !(event.target instanceof Node)) return;
const handleClickOutside = (event: MouseEvent) => {
if (!dropdownRef.current || !(event.target instanceof Node)) return;
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
if (onToggle) onToggle(false);
else setIsOpen(false);
@ -53,19 +54,19 @@ const dropdownRef = useRef<HTMLDivElement>(null);
};
return (
<div className="relative inline-block text-left" ref={dropdownRef}>
<div className="relative w-full" ref={dropdownRef}>
<button
type="button"
onClick={toggleDropdown}
className="inline-flex justify-between items-center w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="dropdown-button"
>
{activeOption ? activeOption.name : label}
<span>
{activeOption ? activeOption.name : label}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="ml-2 h-5 w-5"
viewBox="0 0 20 20"
className={`icon-sm transition-transform duration-200 ${(controlledIsOpen ?? isOpen) ? 'rotate-180' : ''}`}
fill="currentColor"
aria-hidden="true"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
@ -76,26 +77,24 @@ const dropdownRef = useRef<HTMLDivElement>(null);
</button>
{(controlledIsOpen ?? isOpen) && (
<div
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
style={{ zIndex: 50 }} // Set a high z-index value
>
<div className="py-1">
{options.map((option) => (
<button
<div className="absolute z-50 w-full dropdown-menu">
{options.length === 0 ? (
<div className="dropdown-item text-center">
No streams available
</div>
) : (
options.map((option) => (
<div
key={option.id}
type="button"
onClick={() => handleSelect(option)}
className={`block w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 ${
activeOption?.id === option.id ? 'font-bold text-blue-500' : ''
}`}
className={`dropdown-item ${activeOption?.id === option.id ? 'active' : ''}`}
>
{option.name}
</button>
))}
</div>
</div>
))
)}
</div>
)}
</div>
);
}
}

148
components/Footer.tsx Normal file
View file

@ -0,0 +1,148 @@
'use client';
import { useState, useEffect } from 'react';
type OBSStatus = {
host: string;
port: string;
hasPassword: boolean;
connected: boolean;
version?: {
obsVersion: string;
obsWebSocketVersion: string;
};
currentScene?: string;
sceneCount?: number;
streaming?: boolean;
recording?: boolean;
error?: string;
};
export default function Footer() {
const [obsStatus, setObsStatus] = useState<OBSStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchOBSStatus = async () => {
try {
const response = await fetch('/api/obsStatus');
const data = await response.json();
setObsStatus(data);
} catch (error) {
console.error('Failed to fetch OBS status:', error);
} finally {
setIsLoading(false);
}
};
fetchOBSStatus();
// Refresh status every 30 seconds
const interval = setInterval(fetchOBSStatus, 30000);
return () => clearInterval(interval);
}, []);
if (isLoading) {
return (
<footer className="glass p-6 mt-8">
<div className="container text-center">
<div className="text-sm opacity-60">Loading OBS status...</div>
</div>
</footer>
);
}
return (
<footer className="glass p-6 mt-8">
<div className="container">
<div className="grid-2">
{/* Connection Status */}
<div>
<div className="flex items-center gap-3 mb-4">
<div className={`w-3 h-3 rounded-full ${obsStatus?.connected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<div>
<h3 className="font-semibold">OBS Studio</h3>
<p className="text-sm opacity-60">
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
</p>
</div>
</div>
{obsStatus && (
<div className="text-sm opacity-80">
<div>{obsStatus.host}:{obsStatus.port}</div>
{obsStatus.hasPassword && <div>🔒 Authenticated</div>}
</div>
)}
</div>
{/* Live Status */}
{obsStatus?.connected && (
<div>
<h3 className="font-semibold mb-4">Live Status</h3>
<div className="space-y-2 text-sm">
{obsStatus.currentScene && (
<div className="flex justify-between">
<span>Scene:</span>
<span className="font-medium">{obsStatus.currentScene}</span>
</div>
)}
{obsStatus.sceneCount !== null && (
<div className="flex justify-between">
<span>Total Scenes:</span>
<span className="font-medium">{obsStatus.sceneCount}</span>
</div>
)}
<div className="flex gap-4 mt-4">
<div className={`flex items-center gap-2 ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}>
<div className={`w-2 h-2 rounded-full ${obsStatus.streaming ? 'bg-red-500' : 'bg-gray-500'}`}></div>
<span className="text-sm">{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span>
</div>
<div className={`flex items-center gap-2 ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}>
<div className={`w-2 h-2 rounded-full ${obsStatus.recording ? 'bg-red-500' : 'bg-gray-500'}`}></div>
<span className="text-sm">{obsStatus.recording ? 'REC' : 'IDLE'}</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Error Message */}
{obsStatus?.error && (
<div className="mt-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg">
<div className="flex items-center gap-2">
<svg className="icon-sm text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span className="text-sm text-red-300">{obsStatus.error}</span>
</div>
</div>
)}
{/* Version Info */}
<div className="mt-6 pt-4 border-t border-white/20 flex justify-between items-center text-sm opacity-60">
<div className="flex gap-4">
{obsStatus?.version && (
<>
<span>OBS v{obsStatus.version.obsVersion}</span>
<span>WebSocket v{obsStatus.version.obsWebSocketVersion}</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
<span>OBS Stream Manager</span>
</div>
</div>
</div>
</footer>
);
}

66
components/Header.tsx Normal file
View file

@ -0,0 +1,66 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export default function Header() {
const pathname = usePathname();
const isActive = (path: string) => pathname === path;
return (
<header className="glass p-6 mb-8">
<div className="container">
<div className="flex justify-between items-center">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<svg className="icon-md text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z" />
</svg>
</div>
<div>
<h1 className="text-xl font-bold">OBS Stream Manager</h1>
<p className="text-sm opacity-80">Professional Control</p>
</div>
</div>
{/* Navigation */}
<nav className="flex">
<Link
href="/"
className={`btn ${isActive('/') ? 'active' : ''}`}
style={{ marginRight: '12px' }}
>
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
Home
</Link>
<Link
href="/add"
className={`btn ${isActive('/add') ? 'active' : ''}`}
style={{ marginRight: '12px' }}
>
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
Streams
</Link>
<Link
href="/teams"
className={`btn ${isActive('/teams') ? 'active' : ''}`}
>
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
</svg>
Teams
</Link>
</nav>
</div>
</div>
</header>
);
}