Compare commits

...

8 commits

Author SHA1 Message Date
Decobus
7302a38ceb Merge pull request 'text-background-color-source' (#10) from text-background-color-source into main
All checks were successful
Lint and Build / build (push) Successful in 2m49s
Reviewed-on: #10
2025-07-22 23:36:52 +03:00
Decobus
b1215ea82c Fix unused variables and clean up codebase
All checks were successful
Lint and Build / build (pull_request) Successful in 2m47s
- Remove unused NextResponse imports from API routes
- Remove unused result variable in teams DELETE route
- Remove unused Link import from page.tsx
- Remove unused inspectTextSourceProperties function from obsClient.js
- Fix unused catch variables and response variables in test files
- Clean up all ESLint warnings for unused variables
2025-07-22 16:17:23 -04:00
Decobus
d75f599711 Allow Twitch URL or username input in add stream form
All checks were successful
Lint and Build / build (pull_request) Successful in 2m46s
- Add extractTwitchUsername() function to parse various URL formats
- Support https://twitch.tv/username, www.twitch.tv/username, and plain usernames
- Real-time URL parsing - automatically extracts username as user types
- Updated UI labels and placeholder to indicate both input options
- Maintains existing validation and backend compatibility
- Seamless UX - users can paste full URLs or type usernames directly
2025-07-22 16:11:49 -04:00
Decobus
f80f496db5 Resolve merge conflicts in page.tsx and Footer.tsx
- Unified API response format handling to support both old and new formats
- Maintained enhanced UI layout from HEAD branch
- Preserved performance optimizations and smart polling features
- Ensured consistent error handling across components
2025-07-22 16:08:19 -04:00
Decobus
3a0c34e5a0 Reorganize footer layout by moving streaming status to left side
All checks were successful
Lint and Build / build (pull_request) Successful in 2m44s
- Moved OFFLINE/IDLE streaming status from right to left column
- Left side now shows: Connection + Host/Port + Streaming/Recording status
- Right side now shows: Scene info + Team/Stream counts (less crowded)
- Better balance of information between the two columns
- Improves readability and visual hierarchy

Footer layout now:
Left: OBS Studio (connected) + 127.0.0.1:4455 + OFFLINE/IDLE status
Right: Scene info + Teams/Streams counts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 14:37:48 -04:00
Decobus
52f3051c82 Fix status indicator dots visibility in footer
- Added custom CSS for status dots using Solarized theme colors
- Replaced Tailwind classes with custom .status-dot classes
- Fixed connection status dot (green when connected, red when disconnected)
- Fixed streaming/recording status dots (red when active, gray when idle)
- Used proper Solarized color palette for consistency

Status dots now properly display:
- Connection: Green dot for connected, red for disconnected
- Streaming: Red dot when live, gray when offline
- Recording: Red dot when recording, gray when idle

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 14:36:07 -04:00
Decobus
02cad6a319 Add team and stream counts to footer
- Created /api/counts endpoint for database statistics
- Updated footer to display team and stream counts
- Shows counts when OBS is connected (alongside OBS stats)
- Shows database stats when OBS is disconnected (fallback display)
- Polls counts every 60 seconds (less frequent than OBS status)
- Maintains backward compatibility with API response formats

Footer now shows:
- Teams: X
- Streams: Y

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 14:28:14 -04:00
Decobus
cbf8cd6516 Fix API response format compatibility on main page
The streams API now returns standardized format { success: true, data: [...] }
but the frontend was still expecting the old direct array format.

Added backward compatibility to handle both response formats:
- New format: { success: true, data: streams }
- Old format: streams (direct array)

Fixes TypeError: streams.forEach is not a function

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 14:25:46 -04:00
12 changed files with 55 additions and 54 deletions

View file

@ -27,8 +27,6 @@ describe('/api/streams', () => {
mockDb.all.mockResolvedValue(mockStreams);
const _response = await GET();
expect(mockDb.all).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM')
);
@ -40,8 +38,6 @@ describe('/api/streams', () => {
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([]);
});
@ -50,8 +46,6 @@ describe('/api/streams', () => {
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' },
@ -64,8 +58,6 @@ describe('/api/streams', () => {
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' },

View file

@ -46,8 +46,6 @@ describe('/api/teams', () => {
mockDb.all.mockResolvedValue(mockTeams);
const _response = await GET();
expect(mockDb.all).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM')
);
@ -59,8 +57,6 @@ describe('/api/teams', () => {
it('returns empty array when no teams exist', async () => {
mockDb.all.mockResolvedValue([]);
const _response = await GET();
const { createSuccessResponse } = require('@/lib/apiHelpers');
expect(createSuccessResponse).toHaveBeenCalledWith([]);
});
@ -69,8 +65,6 @@ describe('/api/teams', () => {
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);
});
@ -80,8 +74,6 @@ describe('/api/teams', () => {
const { getDatabase } = require('@/lib/database');
getDatabase.mockRejectedValue(connectionError);
const _response = await GET();
const { createDatabaseError } = require('@/lib/apiHelpers');
expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', connectionError);
});

View file

@ -1,4 +1,3 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { TABLE_NAMES } from '../../../lib/constants';
import { createSuccessResponse, createDatabaseError, withErrorHandling } from '../../../lib/apiHelpers';

View file

@ -1,4 +1,3 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { createSuccessResponse, createErrorResponse, withErrorHandling } from '../../../lib/apiHelpers';

View file

@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { TABLE_NAMES } from '../../../lib/constants';
import { createErrorResponse, createSuccessResponse, createDatabaseError, withErrorHandling } from '../../../lib/apiHelpers';

View file

@ -1,4 +1,3 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { StreamWithTeam } from '@/types';
import { TABLE_NAMES } from '../../../lib/constants';

View file

@ -126,7 +126,7 @@ export async function DELETE(
);
// Delete the team
const result = await db.run(
await db.run(
`DELETE FROM ${TABLE_NAMES.TEAMS} WHERE team_id = ?`,
[teamId]
);

View file

@ -1,7 +1,6 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import Link from 'next/link';
import Dropdown from '@/components/Dropdown';
import { useToast } from '@/lib/useToast';
import { ToastContainer } from '@/components/Toast';
@ -75,8 +74,11 @@ export default function Home() {
activeRes.json()
]);
setStreams(streamsData.data);
setActiveSources(activeData.data);
// Handle both old and new API response formats
const streams = streamsData.success ? streamsData.data : streamsData;
const activeSources = activeData.success ? activeData.data : activeData;
setStreams(streams);
setActiveSources(activeSources);
} catch (error) {
console.error('Error fetching data:', error);
showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.');

View file

@ -66,9 +66,37 @@ export default function AddStream() {
}, [fetchData]);
const extractTwitchUsername = (input: string): string => {
const trimmed = input.trim();
// If it's a URL, extract username
const urlPatterns = [
/^https?:\/\/(www\.)?twitch\.tv\/([a-zA-Z0-9_]+)\/?$/,
/^(www\.)?twitch\.tv\/([a-zA-Z0-9_]+)\/?$/,
/^twitch\.tv\/([a-zA-Z0-9_]+)\/?$/
];
for (const pattern of urlPatterns) {
const match = trimmed.match(pattern);
if (match) {
return match[match.length - 1]; // Last capture group is always the username
}
}
// Otherwise assume it's just a username
return trimmed;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Special handling for twitch_username to extract from URL if needed
if (name === 'twitch_username') {
const username = extractTwitchUsername(value);
setFormData((prev) => ({ ...prev, [name]: username }));
} else {
setFormData((prev) => ({ ...prev, [name]: value }));
}
// Clear validation error when user starts typing
if (validationErrors[name]) {
@ -213,7 +241,7 @@ export default function AddStream() {
{/* Twitch Username */}
<div>
<label className="block text-white font-semibold mb-3">
Twitch Username
Twitch Username or URL
</label>
<input
type="text"
@ -224,7 +252,7 @@ export default function AddStream() {
className={`input ${
validationErrors.twitch_username ? 'border-red-500/60 bg-red-500/10' : ''
}`}
placeholder="Enter Twitch username"
placeholder="Enter username or paste full Twitch URL (e.g., 'streamer' or 'https://twitch.tv/streamer')"
/>
{validationErrors.twitch_username && (
<div className="text-red-400 text-sm mt-2">

View file

@ -50,7 +50,9 @@ export default function Footer() {
try {
const response = await fetch('/api/counts');
const data = await response.json();
setCounts(data.data);
// Handle both old and new API response formats
const countsData = data.success ? data.data : data;
setCounts(countsData);
} catch (error) {
console.error('Failed to fetch counts:', error);
}
@ -79,12 +81,14 @@ export default function Footer() {
<div className="grid-2">
{/* Connection Status */}
<div>
<h3 className="font-semibold mb-4">OBS Studio</h3>
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-3 mb-4">
<div className={`status-dot ${obsStatus?.connected ? 'connected' : 'disconnected'}`}></div>
<span className="text-sm">
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
</span>
<div>
<h3 className="font-semibold">OBS Studio</h3>
<p className="text-sm opacity-60">
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
</p>
</div>
</div>
{obsStatus && (

View file

@ -32,7 +32,7 @@ describe('apiHelpers', () => {
describe('createErrorResponse', () => {
it('creates error response with default status 500', () => {
const _response = createErrorResponse('Test Error');
createErrorResponse('Test Error');
expect(NextResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
@ -44,7 +44,7 @@ describe('apiHelpers', () => {
});
it('creates error response with custom status and message', () => {
const _response = createErrorResponse('Test Error', 400, 'Custom message', { detail: 'extra' });
createErrorResponse('Test Error', 400, 'Custom message', { detail: 'extra' });
expect(NextResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
@ -81,7 +81,7 @@ describe('apiHelpers', () => {
describe('createSuccessResponse', () => {
it('creates success response with default status 200', () => {
const data = { test: 'data' };
const _response = createSuccessResponse(data);
createSuccessResponse(data);
expect(NextResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
@ -95,7 +95,7 @@ describe('apiHelpers', () => {
it('creates success response with custom status', () => {
const data = { id: 1, name: 'test' };
const _response = createSuccessResponse(data, 201);
createSuccessResponse(data, 201);
expect(NextResponse.json).toHaveBeenCalledWith(
expect.objectContaining({

View file

@ -244,20 +244,6 @@ async function getAvailableTextInputKind() {
}
}
async function inspectTextSourceProperties(inputKind) {
try {
const obsClient = await getOBSClient();
// Get the default properties for this input kind
const { inputProperties } = await obsClient.call('GetInputDefaultSettings', { inputKind });
console.log(`Default properties for ${inputKind}:`, JSON.stringify(inputProperties, null, 2));
return inputProperties;
} catch (error) {
console.error('Error inspecting text source properties:', error.message);
return null;
}
}
async function createTextSource(sceneName, textSourceName, text) {
try {
@ -394,7 +380,7 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
try {
await obsClient.call('CreateScene', { sceneName: streamGroupName });
console.log(`Created nested scene "${streamGroupName}" for stream grouping`);
} catch (sceneError) {
} catch (error) {
console.log(`Nested scene "${streamGroupName}" might already exist`);
}
@ -457,7 +443,7 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
sourceName: colorSourceName
});
console.log(`Added color source background "${colorSourceName}" to nested scene`);
} catch (e) {
} catch (error) {
console.log('Color source background might already be in nested scene');
}
@ -467,7 +453,7 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
sourceName: textSourceName
});
console.log(`Added text source "${textSourceName}" to nested scene`);
} catch (e) {
} catch (error) {
console.log('Text source might already be in nested scene');
}