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
|
@ -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
148
components/Footer.tsx
Normal 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
66
components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue