diff --git a/data/db.sqlite3 b/data/db.sqlite3 index cdc815c..2d862a4 100644 Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ diff --git a/links/iptv_urls.py b/links/iptv_urls.py index 6b22667..4328d97 100644 --- a/links/iptv_urls.py +++ b/links/iptv_urls.py @@ -4,11 +4,14 @@ from .iptv_views import ( IPTVChannelCreateView, IPTVChannelUpdateView, IPTVChannelDeleteView, + export_m3u_playlist, ) urlpatterns = [ + # UI routes path('ui/iptv/', IPTVChannelListView.as_view(), name='iptv-list'), path('ui/iptv/create/', IPTVChannelCreateView.as_view(), name='iptv-create'), path('ui/iptv//edit/', IPTVChannelUpdateView.as_view(), name='iptv-update'), path('ui/iptv//delete/', IPTVChannelDeleteView.as_view(), name='iptv-delete'), + path('api/iptv/export/', export_m3u_playlist, name='iptv-export'), ] diff --git a/links/iptv_views.py b/links/iptv_views.py index dd7e0bb..84d98f9 100644 --- a/links/iptv_views.py +++ b/links/iptv_views.py @@ -1,6 +1,7 @@ from django.urls import reverse_lazy from django.views.generic import ListView, CreateView, UpdateView, DeleteView from django.shortcuts import render +from django.http import HttpResponse from .models import IPTVChannel from .forms import IPTVChannelForm @@ -31,3 +32,25 @@ class IPTVChannelDeleteView(DeleteView): model = IPTVChannel template_name = 'links/iptv/confirm_delete.html' success_url = reverse_lazy('iptv-list') + +def export_m3u_playlist(request): + """Export all channels as M3U playlist""" + channels = IPTVChannel.objects.all() + + # Start the M3U file content + content = ['#EXTM3U'] + + for channel in channels: + # Add channel info line + content.append(f'#EXTINF:-1 tvg-id="{channel.id}",{channel.title}') + # Add URL line + content.append(channel.url) + + # Join all lines with newlines + playlist_content = '\n'.join(content) + + # Create the response with appropriate headers + response = HttpResponse(playlist_content, content_type='audio/x-mpegurl') + response['Content-Disposition'] = 'attachment; filename="iptv_channels.m3u"' + + return response diff --git a/links/templates/links/iptv/list.html b/links/templates/links/iptv/list.html index f5abb9d..9f8dbb9 100644 --- a/links/templates/links/iptv/list.html +++ b/links/templates/links/iptv/list.html @@ -2,43 +2,51 @@ {% load static %} {% block content %} -
- -
-
-

IPTV Channels

- +
+ +
+
- -
-
-
+ +
+
+
-

No channel selected

+

No channel selected

Select a channel from the list to start watching

- + +
+
+
+ + + + + + @@ -254,5 +334,128 @@ function cleanupPlayer() { emptyState.classList.remove('hidden'); playOverlay.classList.add('hidden'); } + +// Modal handling functions +let currentDeleteId = null; +let lastFocusedElement = null; + +function showAddModal() { + lastFocusedElement = document.activeElement; + document.getElementById('modalTitle').textContent = 'Add Channel'; + document.getElementById('channelId').value = ''; + document.getElementById('channelForm').reset(); + const modal = document.getElementById('channelModal'); + modal.classList.remove('hidden'); + document.getElementById('title').focus(); +} + +function showEditModal(id, title, url, description) { + lastFocusedElement = document.activeElement; + document.getElementById('modalTitle').textContent = 'Edit Channel'; + document.getElementById('channelId').value = id; + document.getElementById('title').value = title; + document.getElementById('url').value = url; + document.getElementById('description').value = description; + const modal = document.getElementById('channelModal'); + modal.classList.remove('hidden'); + document.getElementById('title').focus(); +} + +function hideChannelModal() { + document.getElementById('channelModal').classList.add('hidden'); + if (lastFocusedElement) { + lastFocusedElement.focus(); + } +} + +function showDeleteModal(id, title) { + lastFocusedElement = document.activeElement; + currentDeleteId = id; + document.getElementById('deleteChannelTitle').textContent = title; + document.getElementById('deleteModal').classList.remove('hidden'); +} + +function hideDeleteModal() { + currentDeleteId = null; + document.getElementById('deleteModal').classList.add('hidden'); + if (lastFocusedElement) { + lastFocusedElement.focus(); + } +} + +async function submitChannelForm() { + const form = document.getElementById('channelForm'); + const formData = new FormData(form); + const id = formData.get('id'); + const url = id ? `/ui/iptv/${id}/edit/` : '/ui/iptv/create/'; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + }, + body: formData, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + hideChannelModal(); + window.location.reload(); + } catch (error) { + console.error('Error:', error); + alert('Error saving channel. Please try again.'); + } +} + +async function confirmDelete() { + if (!currentDeleteId) return; + + try { + const response = await fetch(`/ui/iptv/${currentDeleteId}/delete/`, { + method: 'POST', + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + }, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + hideDeleteModal(); + window.location.reload(); + } catch (error) { + console.error('Error:', error); + alert('Error deleting channel. Please try again.'); + } +} + +// Add keyboard event listeners for modal accessibility +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + hideChannelModal(); + hideDeleteModal(); + } +}); + +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} {% endblock %} diff --git a/new_theme/static/css/dist/styles.css b/new_theme/static/css/dist/styles.css index 327a740..5596bf9 100644 --- a/new_theme/static/css/dist/styles.css +++ b/new_theme/static/css/dist/styles.css @@ -588,6 +588,18 @@ video { } } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + .pointer-events-none { pointer-events: none; } @@ -608,6 +620,10 @@ video { position: relative; } +.sticky { + position: sticky; +} + .inset-0 { inset: 0px; } @@ -888,6 +904,14 @@ video { min-height: 0px; } +.min-h-full { + min-height: 100%; +} + +.min-h-\[50vh\] { + min-height: 50vh; +} + .w-12 { width: 3rem; } @@ -1008,6 +1032,10 @@ video { cursor: pointer; } +.touch-manipulation { + touch-action: manipulation; +} + .select-all { -webkit-user-select: all; -moz-user-select: all; @@ -1580,6 +1608,11 @@ video { padding-bottom: 2rem; } +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + .pl-3 { padding-left: 0.75rem; } @@ -1600,6 +1633,14 @@ video { padding-top: 1.25rem; } +.pb-4 { + padding-bottom: 1rem; +} + +.pt-4 { + padding-top: 1rem; +} + .text-left { text-align: left; } @@ -1866,6 +1907,21 @@ video { outline-style: solid; } +.ring-1 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-inset { + --tw-ring-inset: inset; +} + +.ring-gray-300 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); +} + .blur { --tw-blur: blur(8px); filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); @@ -2045,6 +2101,16 @@ video { background-color: rgb(185 28 28 / var(--tw-bg-opacity)); } +.hover\:bg-indigo-500:hover { + --tw-bg-opacity: 1; + background-color: rgb(99 102 241 / var(--tw-bg-opacity)); +} + +.hover\:bg-red-500:hover { + --tw-bg-opacity: 1; + background-color: rgb(239 68 68 / var(--tw-bg-opacity)); +} + .hover\:bg-opacity-30:hover { --tw-bg-opacity: 0.3; } @@ -2114,6 +2180,11 @@ video { color: rgb(153 27 27 / var(--tw-text-opacity)); } +.hover\:text-gray-600:hover { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -2143,6 +2214,11 @@ video { border-color: transparent; } +.focus\:border-indigo-500:focus { + --tw-border-opacity: 1; + border-color: rgb(99 102 241 / var(--tw-border-opacity)); +} + .focus\:outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; @@ -2241,6 +2317,11 @@ video { margin-right: 0px; } + .sm\:my-8 { + margin-top: 2rem; + margin-bottom: 2rem; + } + .sm\:mb-6 { margin-bottom: 1.5rem; } @@ -2325,6 +2406,14 @@ video { width: auto; } + .sm\:w-full { + width: 100%; + } + + .sm\:max-w-lg { + max-width: 32rem; + } + .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2411,6 +2500,10 @@ video { padding: 1.5rem; } + .sm\:p-0 { + padding: 0px; + } + .sm\:px-4 { padding-left: 1rem; padding-right: 1rem; @@ -2476,6 +2569,30 @@ video { display: table-cell; } + .md\:h-20 { + height: 5rem; + } + + .md\:h-\[calc\(100vh-4rem\)\] { + height: calc(100vh - 4rem); + } + + .md\:h-full { + height: 100%; + } + + .md\:min-h-0 { + min-height: 0px; + } + + .md\:w-20 { + width: 5rem; + } + + .md\:w-80 { + width: 20rem; + } + .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -2484,9 +2601,22 @@ video { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .md\:flex-row { + flex-direction: row; + } + .md\:gap-8 { gap: 2rem; } + + .md\:border-r { + border-right-width: 1px; + } + + .md\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } } @media (min-width: 1024px) {