From 3c58ccc5af84c62a5a6ead524f7ca71b491fd045 Mon Sep 17 00:00:00 2001 From: Decobus Date: Sun, 20 Jul 2025 13:16:06 -0400 Subject: [PATCH] Add stream deletion functionality and improve UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add delete button to each stream with confirmation modal - Implement DELETE endpoint that removes sources from OBS before database deletion - Fix dropdown positioning issue when scrolling by removing scroll offsets - Change add stream form to use Twitch username instead of full URL - Automatically calculate Twitch URL from username (https://twitch.tv/{username}) - Add username validation (4-25 chars, alphanumeric and underscores only) - Improve "View Stream" link visibility with button styling - Ensure streams list refreshes immediately after deletion 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/api/streams/[id]/route.ts | 43 ++++++++- app/streams/page.tsx | 163 ++++++++++++++++++++++++++-------- components/Dropdown.tsx | 28 ++++-- 3 files changed, 191 insertions(+), 43 deletions(-) diff --git a/app/api/streams/[id]/route.ts b/app/api/streams/[id]/route.ts index 89cdb70..1a8974e 100644 --- a/app/api/streams/[id]/route.ts +++ b/app/api/streams/[id]/route.ts @@ -1,6 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '../../../../lib/database'; import { TABLE_NAMES } from '../../../../lib/constants'; +import { getOBSClient } from '../../../../lib/obsClient'; + +interface OBSInput { + inputName: string; + inputUuid: string; +} + +interface GetInputListResponse { + inputs: OBSInput[]; +} // GET single stream export async function GET( @@ -106,7 +116,38 @@ export async function DELETE( ); } - // Delete stream + // Try to delete from OBS first + try { + const obs = await getOBSClient(); + console.log('OBS client obtained:', !!obs); + + if (obs && existingStream.obs_source_name) { + console.log(`Attempting to remove OBS source: ${existingStream.obs_source_name}`); + + // Get the input UUID first + const response = await obs.call('GetInputList'); + const inputs = response as GetInputListResponse; + console.log(`Found ${inputs.inputs.length} inputs in OBS`); + + const input = inputs.inputs.find((i: OBSInput) => i.inputName === existingStream.obs_source_name); + + if (input) { + console.log(`Found input with UUID: ${input.inputUuid}`); + await obs.call('RemoveInput', { inputUuid: input.inputUuid }); + console.log(`Successfully removed OBS source: ${existingStream.obs_source_name}`); + } else { + console.log(`Input not found in OBS: ${existingStream.obs_source_name}`); + console.log('Available inputs:', inputs.inputs.map((i: OBSInput) => i.inputName)); + } + } else { + console.log('OBS client not available or no source name provided'); + } + } catch (obsError) { + console.error('Error removing source from OBS:', obsError); + // Continue with database deletion even if OBS removal fails + } + + // Delete stream from database await db.run( `DELETE FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`, [resolvedParams.id] diff --git a/app/streams/page.tsx b/app/streams/page.tsx index 6246b0f..75aa281 100644 --- a/app/streams/page.tsx +++ b/app/streams/page.tsx @@ -18,7 +18,7 @@ export default function AddStream() { const [formData, setFormData] = useState({ name: '', obs_source_name: '', - url: '', + twitch_username: '', team_id: null, }); const [teams, setTeams] = useState<{id: number; name: string}[]>([]); @@ -26,6 +26,8 @@ export default function AddStream() { const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [validationErrors, setValidationErrors] = useState<{[key: string]: string}>({}); + const [deleteConfirm, setDeleteConfirm] = useState<{id: number; name: string} | null>(null); + const [isDeleting, setIsDeleting] = useState(false); const { toasts, removeToast, showSuccess, showError } = useToast(); const fetchData = useCallback(async () => { @@ -65,6 +67,7 @@ export default function AddStream() { fetchData(); }, [fetchData]); + const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); @@ -85,6 +88,32 @@ export default function AddStream() { } }; + const handleDelete = async () => { + if (!deleteConfirm) return; + + setIsDeleting(true); + try { + const response = await fetch(`/api/streams/${deleteConfirm.id}`, { + method: 'DELETE', + }); + + const data = await response.json(); + if (response.ok) { + showSuccess('Stream Deleted', `"${deleteConfirm.name}" has been deleted successfully`); + setDeleteConfirm(null); + // Refetch the streams list + await fetchData(); + } else { + showError('Failed to Delete Stream', data.error || 'Unknown error occurred'); + } + } catch (error) { + console.error('Error deleting stream:', error); + showError('Failed to Delete Stream', 'Network error or server unavailable'); + } finally { + setIsDeleting(false); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -100,14 +129,10 @@ export default function AddStream() { 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.twitch_username.trim()) { + errors.twitch_username = 'Twitch username is required'; + } else if (!/^[a-zA-Z0-9_]{4,25}$/.test(formData.twitch_username.trim())) { + errors.twitch_username = 'Twitch username must be 4-25 characters and contain only letters, numbers, and underscores'; } if (!formData.team_id) { @@ -123,16 +148,21 @@ export default function AddStream() { setIsSubmitting(true); try { + const submissionData = { + ...formData, + url: `https://www.twitch.tv/${formData.twitch_username.trim()}` + }; + const response = await fetch('/api/addStream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData), + body: JSON.stringify(submissionData), }); const data = await response.json(); if (response.ok) { showSuccess('Stream Added', `"${formData.name}" has been added successfully`); - setFormData({ name: '', obs_source_name: '', url: '', team_id: null }); + setFormData({ name: '', obs_source_name: '', twitch_username: '', team_id: null }); setValidationErrors({}); fetchData(); } else { @@ -147,14 +177,15 @@ export default function AddStream() { }; return ( -
- {/* Title */} -
-

Streams

-

- Organize your content by creating and managing stream sources -

-
+ <> +
+ {/* Title */} +
+

Streams

+

+ Organize your content by creating and managing stream sources +

+
{/* Add New Stream */}
@@ -206,25 +237,25 @@ export default function AddStream() { )}
- {/* URL */} + {/* Twitch Username */}
- {validationErrors.url && ( + {validationErrors.twitch_username && (
- {validationErrors.url} + {validationErrors.twitch_username}
)}
@@ -296,11 +327,30 @@ export default function AddStream() {
Team: {team?.name || 'Unknown'}
-
+
ID: {stream.id}
- - View Stream - +
+ + + + + View Stream + + +
@@ -310,8 +360,51 @@ export default function AddStream() { )} - {/* Toast Notifications */} - - + {/* Toast Notifications */} + + + + {/* Delete Confirmation Modal */} + {deleteConfirm && ( +
+
+

Confirm Deletion

+

+ Are you sure you want to delete the stream “{deleteConfirm.name}”? This will remove it from both the database and OBS. +

+
+ + +
+
+
+ )} + ); } \ No newline at end of file diff --git a/components/Dropdown.tsx b/components/Dropdown.tsx index 7ef8199..247c0c2 100644 --- a/components/Dropdown.tsx +++ b/components/Dropdown.tsx @@ -49,13 +49,27 @@ export default function Dropdown({ }, [controlledIsOpen, isOpen, onToggle]); useEffect(() => { - if ((controlledIsOpen ?? isOpen) && buttonRef.current && mounted) { - const rect = buttonRef.current.getBoundingClientRect(); - setDropdownPosition({ - top: rect.bottom + window.scrollY + 4, - left: rect.left + window.scrollX, - width: rect.width - }); + const updatePosition = () => { + if ((controlledIsOpen ?? isOpen) && buttonRef.current && mounted) { + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + 4, + left: rect.left, + width: rect.width + }); + } + }; + + updatePosition(); + + if ((controlledIsOpen ?? isOpen) && mounted) { + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; } }, [controlledIsOpen, isOpen, mounted]);