diff --git a/.gitignore b/.gitignore index c0eb07c..8aef9fb 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,8 @@ logs files/*.db files/*.sqlite files/*.sqlite3 +# But include template database +!files/*.template.db # OS generated files .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 4a36acf..e9b1a8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,12 +25,12 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I - **Backend**: Next.js API routes - **Database**: SQLite with sqlite3 driver - **OBS Integration**: obs-websocket-js for WebSocket communication with OBS Studio -- **Styling**: Custom CSS with Tailwind CSS utilities and modern glass card components +- **Styling**: Solarized Dark theme with CSS custom properties, Tailwind CSS utilities, and accessible glass morphism components ### Project Structure - `/app` - Next.js App Router pages and API routes - `/api` - Backend API endpoints for stream management - - `/add` - Streams management page (add new streams and view existing) + - `/streams` - Streams management page (add new streams and view existing) - `/teams` - Team management page - `/edit/[id]` - Individual stream editing - `/components` - Reusable React components (Header, Footer, Dropdown) @@ -53,7 +53,11 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I - WebSocket API for direct OBS control (source creation, status monitoring) - Text file system for OBS Source Switcher plugin integration (source switching) -4. **Glass Morphism UI Architecture**: Modern design system with backdrop blur effects, gradient backgrounds, and responsive glass card components +4. **Solarized Dark Design System**: Accessible colorblind-friendly theme based on Solarized Dark palette with: + - High contrast ratios (7.5:1+) meeting WCAG AAA standards + - CSS custom properties for maintainable theming + - Glass morphism effects with proper backdrop blur + - Distinctive active navigation states for clear wayfinding 5. **Screen Position Management**: Seven distinct screen positions (large, left, right, topLeft, topRight, bottomLeft, bottomRight) with individual source control diff --git a/README.md b/README.md index 5169f28..9e47f56 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,26 @@ Without an API key, anyone on your network can control your OBS streams. ### Database Setup +The project includes an empty template database for easy setup: + ```bash -# Create seasonal database tables +# Option 1: Use template database directly (development) +# Database will be created in ./files/sources.db +npm run create-sat-summer-2025-tables + +# Option 2: Set up custom database location (recommended) +# 1. Copy the template database +cp files/sources.template.db /path/to/your/database/sources.db + +# 2. Set environment variable in .env.local +echo "FILE_DIRECTORY=/path/to/your/database" >> .env.local + +# 3. Create tables in your custom database npm run create-sat-summer-2025-tables ``` +**Template Database**: The repository includes `files/sources.template.db` with the proper schema but no data. Your local development database (`sources.db`) is automatically ignored by git to prevent committing personal data. + ## Development Commands ```bash diff --git a/app/api/__tests__/streams.test.ts b/app/api/__tests__/streams.test.ts new file mode 100644 index 0000000..d4ef664 --- /dev/null +++ b/app/api/__tests__/streams.test.ts @@ -0,0 +1,76 @@ +import { GET } from '../streams/route'; + +// Mock the database module +jest.mock('@/lib/database', () => ({ + getDatabase: jest.fn(), +})); + +describe('/api/streams', () => { + let mockDb: any; + + beforeEach(() => { + // Create mock database + mockDb = { + all: jest.fn(), + }; + + const { getDatabase } = require('@/lib/database'); + getDatabase.mockResolvedValue(mockDb); + }); + + describe('GET /api/streams', () => { + it('returns all streams successfully', async () => { + const mockStreams = [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1', obs_source_name: 'Source 1', team_id: 1 }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2', obs_source_name: 'Source 2', team_id: 2 }, + ]; + + mockDb.all.mockResolvedValue(mockStreams); + + const response = await GET(); + + expect(mockDb.all).toHaveBeenCalledWith( + expect.stringContaining('SELECT * FROM') + ); + + const { NextResponse } = require('next/server'); + expect(NextResponse.json).toHaveBeenCalledWith(mockStreams); + }); + + it('returns empty array when no streams exist', async () => { + mockDb.all.mockResolvedValue([]); + + const response = await GET(); + + const { NextResponse } = require('next/server'); + expect(NextResponse.json).toHaveBeenCalledWith([]); + }); + + it('handles database errors gracefully', async () => { + const dbError = new Error('Database connection failed'); + mockDb.all.mockRejectedValue(dbError); + + const response = await GET(); + + const { NextResponse } = require('next/server'); + expect(NextResponse.json).toHaveBeenCalledWith( + { error: 'Failed to fetch streams' }, + { status: 500 } + ); + }); + + it('handles database connection errors', async () => { + const connectionError = new Error('Failed to connect to database'); + const { getDatabase } = require('@/lib/database'); + getDatabase.mockRejectedValue(connectionError); + + const response = await GET(); + + const { NextResponse } = require('next/server'); + expect(NextResponse.json).toHaveBeenCalledWith( + { error: 'Failed to fetch streams' }, + { status: 500 } + ); + }); + }); +}); \ No newline at end of file diff --git a/app/api/__tests__/teams.test.ts b/app/api/__tests__/teams.test.ts new file mode 100644 index 0000000..4f26d27 --- /dev/null +++ b/app/api/__tests__/teams.test.ts @@ -0,0 +1,89 @@ +import { GET } from '../teams/route'; + +// Mock the database module +jest.mock('@/lib/database', () => ({ + getDatabase: jest.fn(), +})); + +// Mock the apiHelpers module +jest.mock('@/lib/apiHelpers', () => ({ + withErrorHandling: jest.fn((handler) => handler), + createSuccessResponse: jest.fn((data, status = 200) => ({ + data, + status, + json: async () => ({ success: true, data }), + })), + createDatabaseError: jest.fn((operation, error) => ({ + error: 'Database Error', + status: 500, + json: async () => ({ + error: 'Database Error', + message: `Database operation failed: ${operation}`, + }), + })), +})); + +describe('/api/teams', () => { + let mockDb: any; + + beforeEach(() => { + // Create mock database + mockDb = { + all: jest.fn(), + }; + + const { getDatabase } = require('@/lib/database'); + getDatabase.mockResolvedValue(mockDb); + }); + + describe('GET /api/teams', () => { + it('returns all teams successfully', async () => { + const mockTeams = [ + { team_id: 1, team_name: 'Team Alpha' }, + { team_id: 2, team_name: 'Team Beta' }, + { team_id: 3, team_name: 'Team Gamma' }, + ]; + + mockDb.all.mockResolvedValue(mockTeams); + + const response = await GET(); + + expect(mockDb.all).toHaveBeenCalledWith( + expect.stringContaining('SELECT * FROM') + ); + + const { createSuccessResponse } = require('@/lib/apiHelpers'); + expect(createSuccessResponse).toHaveBeenCalledWith(mockTeams); + }); + + it('returns empty array when no teams exist', async () => { + mockDb.all.mockResolvedValue([]); + + const response = await GET(); + + const { createSuccessResponse } = require('@/lib/apiHelpers'); + expect(createSuccessResponse).toHaveBeenCalledWith([]); + }); + + it('handles database errors gracefully', async () => { + const dbError = new Error('Table does not exist'); + mockDb.all.mockRejectedValue(dbError); + + const response = await GET(); + + const { createDatabaseError } = require('@/lib/apiHelpers'); + expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', dbError); + }); + + it('handles database connection errors', async () => { + const connectionError = new Error('Failed to connect to database'); + const { getDatabase } = require('@/lib/database'); + getDatabase.mockRejectedValue(connectionError); + + const response = await GET(); + + const { createDatabaseError } = require('@/lib/apiHelpers'); + expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', connectionError); + }); + }); +}); \ No newline at end of file diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts index 8fe190d..ce99d57 100644 --- a/app/api/teams/route.ts +++ b/app/api/teams/route.ts @@ -2,36 +2,95 @@ import { NextResponse } from 'next/server'; import { getDatabase } from '../../../lib/database'; import { Team } from '@/types'; import { TABLE_NAMES } from '@/lib/constants'; +import { + withErrorHandling, + createSuccessResponse, + createValidationError, + createDatabaseError, + parseRequestBody +} from '@/lib/apiHelpers'; -export async function GET() { - const db = await getDatabase(); - const teams: Team[] = await db.all(`SELECT * FROM ${TABLE_NAMES.TEAMS}`); - return NextResponse.json(teams); +// Validation for team creation +function validateTeamInput(data: unknown): { + valid: boolean; + data?: { team_name: string }; + errors?: Record +} { + const errors: Record = {}; + + if (!data || typeof data !== 'object') { + errors.general = 'Request body must be an object'; + return { valid: false, errors }; + } + + const { team_name } = data as { team_name?: unknown }; + + if (!team_name || typeof team_name !== 'string') { + errors.team_name = 'Team name is required and must be a string'; + } else if (team_name.trim().length < 2) { + errors.team_name = 'Team name must be at least 2 characters long'; + } else if (team_name.trim().length > 50) { + errors.team_name = 'Team name must be less than 50 characters long'; + } + + if (Object.keys(errors).length > 0) { + return { valid: false, errors }; + } + + return { + valid: true, + data: { team_name: team_name.trim() } + }; } -export async function POST(request: Request) { - try { - const { team_name } = await request.json(); - - if (!team_name) { - return NextResponse.json({ error: 'Team name is required' }, { status: 400 }); - } +export const GET = withErrorHandling(async () => { + try { + const db = await getDatabase(); + const teams: Team[] = await db.all(`SELECT * FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`); + + return createSuccessResponse(teams); + } catch (error) { + return createDatabaseError('fetch teams', error); + } +}); - const db = await getDatabase(); - - const result = await db.run( - `INSERT INTO ${TABLE_NAMES.TEAMS} (team_name) VALUES (?)`, - [team_name] - ); - - const newTeam: Team = { - team_id: result.lastID!, - team_name: team_name - }; - - return NextResponse.json(newTeam, { status: 201 }); - } catch (error) { - console.error('Error creating team:', error); - return NextResponse.json({ error: 'Failed to create team' }, { status: 500 }); +export const POST = withErrorHandling(async (request: Request) => { + const bodyResult = await parseRequestBody(request, validateTeamInput); + + if (!bodyResult.success) { + return bodyResult.response; + } + + const { team_name } = bodyResult.data; + + try { + const db = await getDatabase(); + + // Check if team name already exists + const existingTeam = await db.get( + `SELECT team_id FROM ${TABLE_NAMES.TEAMS} WHERE LOWER(team_name) = LOWER(?)`, + [team_name] + ); + + if (existingTeam) { + return createValidationError( + 'Team name already exists', + { team_name: 'A team with this name already exists' } + ); } -} \ No newline at end of file + + const result = await db.run( + `INSERT INTO ${TABLE_NAMES.TEAMS} (team_name) VALUES (?)`, + [team_name] + ); + + const newTeam: Team = { + team_id: result.lastID!, + team_name: team_name + }; + + return createSuccessResponse(newTeam, 201); + } catch (error) { + return createDatabaseError('create team', error); + } +}); \ No newline at end of file diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 2feb090..6fe9463 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -4,6 +4,8 @@ import { useState, useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Dropdown from '@/components/Dropdown'; import { Team } from '@/types'; +import { useToast } from '@/lib/useToast'; +import { ToastContainer } from '@/components/Toast'; type Stream = { id: number; @@ -31,10 +33,11 @@ export default function EditStream() { }); const [teams, setTeams] = useState([]); - const [message, setMessage] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoading, setIsLoading] = useState(true); const [stream, setStream] = useState(null); + const [validationErrors, setValidationErrors] = useState<{[key: string]: string}>({}); + const { toasts, removeToast, showSuccess, showError } = useToast(); // Fetch stream data and teams useEffect(() => { @@ -62,16 +65,19 @@ export default function EditStream() { team_id: streamData.team_id, }); + // Handle both old and new API response formats + const teams = teamsData.success ? teamsData.data : teamsData; + // Map teams for dropdown setTeams( - teamsData.map((team: Team) => ({ + teams.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'); + showError('Failed to Load Stream', 'Could not fetch stream data. Please refresh the page.'); } finally { setIsLoading(false); } @@ -85,15 +91,57 @@ export default function EditStream() { const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); + + // Clear validation error when user starts typing + if (validationErrors[name]) { + setValidationErrors(prev => ({ ...prev, [name]: '' })); + } }; const handleTeamSelect = (teamId: number) => { setFormData((prev) => ({ ...prev, team_id: teamId })); + + // Clear validation error when user selects team + if (validationErrors.team_id) { + setValidationErrors(prev => ({ ...prev, team_id: '' })); + } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setMessage(''); + + // Client-side validation + const errors: {[key: string]: string} = {}; + if (!formData.name.trim()) { + errors.name = 'Stream name is required'; + } else if (formData.name.trim().length < 2) { + errors.name = 'Stream name must be at least 2 characters'; + } + + if (!formData.obs_source_name.trim()) { + errors.obs_source_name = 'OBS source name is required'; + } + + if (!formData.url.trim()) { + errors.url = 'Stream URL is required'; + } else { + try { + new URL(formData.url); + } catch { + errors.url = 'Please enter a valid URL'; + } + } + + if (!formData.team_id) { + errors.team_id = 'Please select a team'; + } + + setValidationErrors(errors); + if (Object.keys(errors).length > 0) { + showError('Validation Error', 'Please fix the form errors'); + return; + } + setIsSubmitting(true); try { @@ -105,17 +153,17 @@ export default function EditStream() { const data = await response.json(); if (response.ok) { - setMessage('Stream updated successfully!'); + showSuccess('Stream Updated', `"${formData.name}" has been updated successfully`); // Redirect back to home after a short delay setTimeout(() => { router.push('/'); }, 1500); } else { - setMessage(data.error || 'Something went wrong.'); + showError('Failed to Update Stream', data.error || 'Unknown error occurred'); } } catch (error) { console.error('Error updating stream:', error); - setMessage('Failed to update stream.'); + showError('Failed to Update Stream', 'Network error or server unavailable'); } finally { setIsSubmitting(false); } @@ -133,17 +181,17 @@ export default function EditStream() { const data = await response.json(); if (response.ok) { - setMessage('Stream deleted successfully!'); + showSuccess('Stream Deleted', `"${stream?.name || 'Stream'}" has been deleted successfully`); // Redirect back to home after a short delay setTimeout(() => { router.push('/'); }, 1500); } else { - setMessage(data.error || 'Failed to delete stream.'); + showError('Failed to Delete Stream', data.error || 'Unknown error occurred'); } } catch (error) { console.error('Error deleting stream:', error); - setMessage('Failed to delete stream.'); + showError('Failed to Delete Stream', 'Network error or server unavailable'); } }; @@ -165,6 +213,7 @@ export default function EditStream() {

Stream Not Found

The requested stream could not be found.

@@ -252,65 +301,39 @@ export default function EditStream() { -
+
- {/* Success/Error Message */} - {message && ( -
-
-
- {message.includes('successfully') ? ( - - - - ) : ( - - - - )} -
- {message} -
-
- )} + + {/* Toast Notifications */} + ); } \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 28e4099..955ad62 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,26 @@ @tailwind components; @tailwind utilities; +/* Solarized Dark Theme Variables */ +:root { + --solarized-base03: #002b36; + --solarized-base02: #073642; + --solarized-base01: #586e75; + --solarized-base00: #657b83; + --solarized-base0: #839496; + --solarized-base1: #93a1a1; + --solarized-base2: #eee8d5; + --solarized-base3: #fdf6e3; + --solarized-blue: #268bd2; + --solarized-cyan: #2aa198; + --solarized-green: #859900; + --solarized-yellow: #b58900; + --solarized-orange: #cb4b16; + --solarized-red: #dc322f; + --solarized-magenta: #d33682; + --solarized-violet: #6c71c4; +} + /* Modern CSS Foundation */ * { margin: 0; @@ -14,25 +34,25 @@ html { } body { - background: linear-gradient(135deg, #1e293b 0%, #312e81 50%, #1e293b 100%); - color: white; + background: linear-gradient(135deg, #002b36 0%, #073642 50%, #002b36 100%); + color: #93a1a1; min-height: 100vh; line-height: 1.6; } /* Glass Card Component */ .glass { - background: rgba(255, 255, 255, 0.1); + background: rgba(7, 54, 66, 0.4); backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); + border: 1px solid rgba(88, 110, 117, 0.3); border-radius: 16px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); } -/* Modern Button */ +/* Modern Button System */ .btn { - background: linear-gradient(135deg, #3b82f6, #1d4ed8); - color: white; + background: linear-gradient(135deg, #268bd2, #2aa198); + color: #fdf6e3; border: none; padding: 12px 24px; border-radius: 12px; @@ -41,58 +61,137 @@ body { transition: all 0.3s ease; display: inline-flex; align-items: center; + justify-content: center; gap: 8px; position: relative; + min-height: 44px; + font-size: 14px; + text-decoration: none; } .btn.active { - background: linear-gradient(135deg, #1d4ed8, #1e40af); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); + background: linear-gradient(135deg, #859900, #b58900); + color: #fdf6e3; + box-shadow: 0 0 0 3px rgba(133, 153, 0, 0.5); + transform: translateY(-1px); + font-weight: 700; } .btn:hover { - transform: translateY(-2px); - box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4); + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(38, 139, 210, 0.4); } +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Secondary Button */ .btn-secondary { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(88, 110, 117, 0.3); + border: 1px solid rgba(131, 148, 150, 0.4); + color: #93a1a1; + backdrop-filter: blur(10px); } .btn-secondary:hover { - background: rgba(255, 255, 255, 0.2); - box-shadow: 0 8px 25px rgba(255, 255, 255, 0.1); + background: rgba(88, 110, 117, 0.5); + border-color: rgba(131, 148, 150, 0.6); + box-shadow: 0 6px 20px rgba(88, 110, 117, 0.3); +} + +/* Success Button */ +.btn-success { + background: linear-gradient(135deg, #859900, #b58900); + color: #fdf6e3; +} + +.btn-success:hover { + background: linear-gradient(135deg, #b58900, #859900); + box-shadow: 0 6px 20px rgba(133, 153, 0, 0.4); +} + +/* Danger Button */ +.btn-danger { + background: linear-gradient(135deg, #dc322f, #cb4b16); + color: #fdf6e3; +} + +.btn-danger:hover { + background: linear-gradient(135deg, #cb4b16, #dc322f); + box-shadow: 0 6px 20px rgba(220, 50, 47, 0.4); +} + +/* Small Button */ +.btn-sm { + padding: 8px 16px; + font-size: 12px; + min-height: 36px; + border-radius: 8px; +} + +/* Icon Only Button */ +.btn-icon { + padding: 10px; + min-width: 44px; + aspect-ratio: 1; +} + +/* Button with icon spacing */ +.btn .icon { + flex-shrink: 0; +} + +/* Form spacing fixes since Tailwind gap classes aren't working */ +.form-row { + display: flex; + align-items: center; +} + +.form-row > * + * { + margin-left: 16px; +} + +.button-group { + display: flex; + align-items: center; +} + +.button-group > * + * { + margin-left: 12px; } /* Input Styling */ .input { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(7, 54, 66, 0.6); + border: 1px solid rgba(88, 110, 117, 0.4); border-radius: 12px; padding: 12px 16px; - color: white; + color: #93a1a1; width: 100%; transition: all 0.3s ease; } .input::placeholder { - color: rgba(255, 255, 255, 0.6); + color: rgba(131, 148, 150, 0.6); } .input:focus { outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); + border-color: #268bd2; + box-shadow: 0 0 0 3px rgba(38, 139, 210, 0.2); } /* Dropdown Styling */ .dropdown-button { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(7, 54, 66, 0.6); + border: 1px solid rgba(88, 110, 117, 0.4); border-radius: 12px; padding: 12px 16px; - color: white; + color: #93a1a1; width: 100%; text-align: left; cursor: pointer; @@ -103,24 +202,25 @@ body { } .dropdown-button:hover { - background: rgba(255, 255, 255, 0.15); + background: rgba(7, 54, 66, 0.8); } .dropdown-menu { - background: rgba(255, 255, 255, 0.1); + background: rgba(0, 43, 54, 0.95); backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); + border: 1px solid rgba(88, 110, 117, 0.4); border-radius: 12px; margin-top: 4px; overflow: hidden; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); } .dropdown-item { padding: 12px 16px; cursor: pointer; transition: all 0.2s ease; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + border-bottom: 1px solid rgba(88, 110, 117, 0.2); + color: #93a1a1; } .dropdown-item:last-child { @@ -128,12 +228,12 @@ body { } .dropdown-item:hover { - background: rgba(255, 255, 255, 0.15); + background: rgba(38, 139, 210, 0.3); } .dropdown-item.active { - background: rgba(59, 130, 246, 0.2); - color: #93c5fd; + background: rgba(38, 139, 210, 0.3); + color: #fdf6e3; } /* Icon Sizes */ @@ -199,7 +299,7 @@ body { .subtitle { font-size: 1.125rem; - color: rgba(255, 255, 255, 0.8); + color: rgba(131, 148, 150, 0.9); text-align: center; margin-bottom: 32px; } diff --git a/app/layout.tsx b/app/layout.tsx index 2d6c46e..7358406 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,8 @@ import './globals.css'; import Header from '@/components/Header'; import Footer from '@/components/Footer'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import PerformanceDashboard from '@/components/PerformanceDashboard'; export const metadata = { title: 'OBS Source Switcher', @@ -12,8 +14,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
-
{children}
+
+ + {children} + +