Add OBS group management feature #3
3 changed files with 191 additions and 43 deletions
|
@ -1,6 +1,16 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../../lib/database';
|
import { getDatabase } from '../../../../lib/database';
|
||||||
import { TABLE_NAMES } from '../../../../lib/constants';
|
import { TABLE_NAMES } from '../../../../lib/constants';
|
||||||
|
import { getOBSClient } from '../../../../lib/obsClient';
|
||||||
|
|
||||||
|
interface OBSInput {
|
||||||
|
inputName: string;
|
||||||
|
inputUuid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetInputListResponse {
|
||||||
|
inputs: OBSInput[];
|
||||||
|
}
|
||||||
|
|
||||||
// GET single stream
|
// GET single stream
|
||||||
export async function GET(
|
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(
|
await db.run(
|
||||||
`DELETE FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
`DELETE FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
||||||
[resolvedParams.id]
|
[resolvedParams.id]
|
||||||
|
|
|
@ -18,7 +18,7 @@ export default function AddStream() {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
obs_source_name: '',
|
obs_source_name: '',
|
||||||
url: '',
|
twitch_username: '',
|
||||||
team_id: null,
|
team_id: null,
|
||||||
});
|
});
|
||||||
const [teams, setTeams] = useState<{id: number; name: string}[]>([]);
|
const [teams, setTeams] = useState<{id: number; name: string}[]>([]);
|
||||||
|
@ -26,6 +26,8 @@ export default function AddStream() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [validationErrors, setValidationErrors] = useState<{[key: string]: string}>({});
|
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 { toasts, removeToast, showSuccess, showError } = useToast();
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
|
@ -65,6 +67,7 @@ export default function AddStream() {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
@ -100,14 +129,10 @@ export default function AddStream() {
|
||||||
errors.obs_source_name = 'OBS source name is required';
|
errors.obs_source_name = 'OBS source name is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.url.trim()) {
|
if (!formData.twitch_username.trim()) {
|
||||||
errors.url = 'Stream URL is required';
|
errors.twitch_username = 'Twitch username is required';
|
||||||
} else {
|
} else if (!/^[a-zA-Z0-9_]{4,25}$/.test(formData.twitch_username.trim())) {
|
||||||
try {
|
errors.twitch_username = 'Twitch username must be 4-25 characters and contain only letters, numbers, and underscores';
|
||||||
new URL(formData.url);
|
|
||||||
} catch {
|
|
||||||
errors.url = 'Please enter a valid URL';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.team_id) {
|
if (!formData.team_id) {
|
||||||
|
@ -123,16 +148,21 @@ export default function AddStream() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const submissionData = {
|
||||||
|
...formData,
|
||||||
|
url: `https://www.twitch.tv/${formData.twitch_username.trim()}`
|
||||||
|
};
|
||||||
|
|
||||||
const response = await fetch('/api/addStream', {
|
const response = await fetch('/api/addStream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(formData),
|
body: JSON.stringify(submissionData),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showSuccess('Stream Added', `"${formData.name}" has been added successfully`);
|
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({});
|
setValidationErrors({});
|
||||||
fetchData();
|
fetchData();
|
||||||
} else {
|
} else {
|
||||||
|
@ -147,6 +177,7 @@ export default function AddStream() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="container section">
|
<div className="container section">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
|
@ -206,25 +237,25 @@ export default function AddStream() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* URL */}
|
{/* Twitch Username */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-white font-semibold mb-3">
|
<label className="block text-white font-semibold mb-3">
|
||||||
Stream URL
|
Twitch Username
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="text"
|
||||||
name="url"
|
name="twitch_username"
|
||||||
value={formData.url}
|
value={formData.twitch_username}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
className={`input ${
|
className={`input ${
|
||||||
validationErrors.url ? 'border-red-500/60 bg-red-500/10' : ''
|
validationErrors.twitch_username ? 'border-red-500/60 bg-red-500/10' : ''
|
||||||
}`}
|
}`}
|
||||||
placeholder="https://example.com/stream"
|
placeholder="Enter Twitch username"
|
||||||
/>
|
/>
|
||||||
{validationErrors.url && (
|
{validationErrors.twitch_username && (
|
||||||
<div className="text-red-400 text-sm mt-2">
|
<div className="text-red-400 text-sm mt-2">
|
||||||
{validationErrors.url}
|
{validationErrors.twitch_username}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -296,11 +327,30 @@ export default function AddStream() {
|
||||||
<div className="text-sm text-white/60">Team: {team?.name || 'Unknown'}</div>
|
<div className="text-sm text-white/60">Team: {team?.name || 'Unknown'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right space-y-2">
|
||||||
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
||||||
<a href={stream.url} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-400 hover:text-blue-300">
|
<div className="flex gap-2 justify-end">
|
||||||
|
<a
|
||||||
|
href={stream.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-primary text-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
View Stream
|
View Stream
|
||||||
</a>
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm({ id: stream.id, name: stream.name })}
|
||||||
|
className="btn btn-danger text-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -313,5 +363,48 @@ export default function AddStream() {
|
||||||
{/* Toast Notifications */}
|
{/* Toast Notifications */}
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 9999
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="glass p-6" style={{ maxWidth: '28rem', width: '90%' }}>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">Confirm Deletion</h3>
|
||||||
|
<p className="text-white/80 mb-6">
|
||||||
|
Are you sure you want to delete the stream “{deleteConfirm.name}”? This will remove it from both the database and OBS.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="btn btn-danger"
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete Stream'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -49,14 +49,28 @@ export default function Dropdown({
|
||||||
}, [controlledIsOpen, isOpen, onToggle]);
|
}, [controlledIsOpen, isOpen, onToggle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const updatePosition = () => {
|
||||||
if ((controlledIsOpen ?? isOpen) && buttonRef.current && mounted) {
|
if ((controlledIsOpen ?? isOpen) && buttonRef.current && mounted) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
setDropdownPosition({
|
setDropdownPosition({
|
||||||
top: rect.bottom + window.scrollY + 4,
|
top: rect.bottom + 4,
|
||||||
left: rect.left + window.scrollX,
|
left: rect.left,
|
||||||
width: rect.width
|
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]);
|
}, [controlledIsOpen, isOpen, mounted]);
|
||||||
|
|
||||||
const activeOption = options.find((option) => option.id === activeId) || null;
|
const activeOption = options.find((option) => option.id === activeId) || null;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue