This commit is contained in:
SergeantPanda 2025-03-01 18:53:01 -06:00
commit 1280813b32
269 changed files with 2324 additions and 46168 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ __pycache__/
*.pyc
node_modules/
.history/
staticfiles/

View file

@ -0,0 +1,47 @@
# Generated by Django 5.1.6 on 2025-03-02 00:01
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('channels', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('avatar_config', models.JSONField(blank=True, default=dict, null=True)),
('channel_groups', models.ManyToManyField(blank=True, related_name='users', to='channels.channelgroup')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View file

@ -4,23 +4,31 @@ from .models import Stream, Channel, ChannelGroup
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = (
'id', 'name', 'group_name', 'custom_url',
'current_viewers', 'updated_at',
'id', # Primary Key
'name',
'group_name',
'custom_url',
'current_viewers',
'updated_at',
)
list_filter = ('group_name',)
search_fields = ('name', 'custom_url', 'group_name')
search_fields = ('id', 'name', 'custom_url', 'group_name') # Added 'id' for searching by ID
ordering = ('-updated_at',)
@admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin):
list_display = (
'channel_number', 'channel_name', 'channel_group', 'tvg_name'
'id', # Primary Key
'channel_number',
'channel_name',
'channel_group',
'tvg_name'
)
list_filter = ('channel_group',)
search_fields = ('channel_name', 'channel_group__name', 'tvg_name')
search_fields = ('id', 'channel_name', 'channel_group__name', 'tvg_name') # Added 'id'
ordering = ('channel_number',)
@admin.register(ChannelGroup)
class ChannelGroupAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
list_display = ('id', 'name') # Added 'id'
search_fields = ('id', 'name') # Added 'id'

View file

@ -5,7 +5,7 @@ from .api_views import (
ChannelViewSet,
ChannelGroupViewSet,
BulkDeleteStreamsAPIView,
BulkDeleteChannelsViewSet
BulkDeleteChannelsAPIView
)
app_name = 'channels' # for DRF routing
@ -14,11 +14,11 @@ router = DefaultRouter()
router.register(r'streams', StreamViewSet, basename='stream')
router.register(r'groups', ChannelGroupViewSet, basename='channel-group')
router.register(r'channels', ChannelViewSet, basename='channel')
router.register(r'bulk-delete-channels', BulkDeleteChannelsViewSet, basename='bulk-delete-channels')
urlpatterns = [
# Bulk delete for streams is a single APIView, not a ViewSet
# Bulk delete is a single APIView, not a ViewSet
path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'),
path('channels/bulk-delete/', BulkDeleteChannelsAPIView.as_view(), name='bulk_delete_channels'),
]
urlpatterns += router.urls

View file

@ -265,7 +265,7 @@ class BulkDeleteStreamsAPIView(APIView):
# ─────────────────────────────────────────────────────────
# 5) Bulk Delete Channels
# ─────────────────────────────────────────────────────────
class BulkDeleteChannelsViewSet(viewsets.ViewSet):
class BulkDeleteChannelsAPIView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(

View file

@ -1,24 +0,0 @@
from django.core.management.base import BaseCommand
from apps.channels.models import Stream, Channel, ChannelGroup
from apps.m3u.models import M3UAccount
class Command(BaseCommand):
help = "Delete all Channels, Streams, M3Us from the database (example)."
def handle(self, *args, **kwargs):
# Delete all Streams
stream_count = Stream.objects.count()
Stream.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {stream_count} Streams."))
# Or delete Channels:
channel_count = Channel.objects.count()
Channel.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {channel_count} Channels."))
# If you have M3UAccount:
m3u_count = M3UAccount.objects.count()
M3UAccount.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {m3u_count} M3U accounts."))
self.stdout.write(self.style.SUCCESS("Successfully deleted the requested objects."))

View file

@ -0,0 +1,61 @@
# Generated by Django 5.1.6 on 2025-03-02 00:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0001_initial'),
('m3u', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ChannelGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
],
),
migrations.CreateModel(
name='Stream',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Default Stream', max_length=255)),
('url', models.URLField()),
('custom_url', models.URLField(blank=True, max_length=2000, null=True)),
('logo_url', models.URLField(blank=True, max_length=2000, null=True)),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('local_file', models.FileField(blank=True, null=True, upload_to='uploads/')),
('current_viewers', models.PositiveIntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
('group_name', models.CharField(blank=True, max_length=255, null=True)),
('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='streams', to='m3u.m3uaccount')),
('stream_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='streams', to='core.streamprofile')),
],
options={
'verbose_name': 'Stream',
'verbose_name_plural': 'Streams',
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='Channel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel_number', models.IntegerField()),
('channel_name', models.CharField(max_length=255)),
('logo_url', models.URLField(blank=True, max_length=2000, null=True)),
('logo_file', models.ImageField(blank=True, null=True, upload_to='logos/')),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('tvg_name', models.CharField(blank=True, max_length=255, null=True)),
('stream_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='core.streamprofile')),
('channel_group', models.ForeignKey(blank=True, help_text='Channel group this channel belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='channels.channelgroup')),
('streams', models.ManyToManyField(blank=True, related_name='channels', to='channels.stream')),
],
),
]

View file

@ -1,16 +1,31 @@
# apps/channels/signals.py
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Channel, Stream
@receiver(m2m_changed, sender=Channel.streams.through)
def update_channel_tvg_id(sender, instance, action, reverse, model, pk_set, **kwargs):
# When streams are added to a channel...
def update_channel_tvg_id_and_logo(sender, instance, action, reverse, model, pk_set, **kwargs):
"""
Whenever streams are added to a channel:
1) If the channel doesn't have a tvg_id, fill it from the first newly-added stream that has one.
2) If the channel doesn't have a logo_url, fill it from the first newly-added stream that has one.
This way if an M3U or EPG entry carried a logo, newly created channels automatically get that logo.
"""
# We only care about post_add, i.e. once the new streams are fully associated
if action == "post_add":
# If the channel does not already have a tvg-id...
# --- 1) Populate channel.tvg_id if empty ---
if not instance.tvg_id:
# Look for any of the newly added streams that have a nonempty tvg_id.
# Look for newly added streams that have a nonempty tvg_id
streams_with_tvg = model.objects.filter(pk__in=pk_set).exclude(tvg_id__exact='')
if streams_with_tvg.exists():
# Update the channel's tvg_id with the first found tvg_id.
instance.tvg_id = streams_with_tvg.first().tvg_id
instance.save(update_fields=['tvg_id'])
# --- 2) Populate channel.logo_url if empty ---
if not instance.logo_url:
# Look for newly added streams that have a nonempty logo_url
streams_with_logo = model.objects.filter(pk__in=pk_set).exclude(logo_url__exact='')
if streams_with_logo.exists():
instance.logo_url = streams_with_logo.first().logo_url
instance.save(update_fields=['logo_url'])

View file

@ -0,0 +1,46 @@
# Generated by Django 5.1.6 on 2025-03-02 00:01
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Settings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('server_name', models.CharField(default='Dispatcharr', max_length=255)),
('time_zone', models.CharField(default='UTC', max_length=50)),
('default_logo_url', models.URLField(blank=True, null=True)),
('max_concurrent_streams', models.PositiveIntegerField(default=10)),
('auto_backup_frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='weekly', max_length=50)),
('enable_debug_logs', models.BooleanField(default=False)),
('schedules_direct_username', models.CharField(blank=True, max_length=255, null=True)),
('schedules_direct_password', models.CharField(blank=True, max_length=255, null=True)),
('schedules_direct_update_frequency', models.CharField(choices=[('12h', 'Every 12 Hours'), ('daily', 'Daily')], default='daily', max_length=50)),
('schedules_direct_api_key', models.CharField(blank=True, max_length=255, null=True)),
('transcoding_bitrate', models.PositiveIntegerField(default=2000)),
('transcoding_audio_codec', models.CharField(choices=[('aac', 'AAC'), ('mp3', 'MP3')], default='aac', max_length=50)),
('transcoding_resolution', models.CharField(choices=[('720p', '720p'), ('1080p', '1080p')], default='1080p', max_length=50)),
('failover_behavior', models.CharField(choices=[('sequential', 'Sequential'), ('random', 'Random')], default='sequential', max_length=50)),
('stream_health_check_frequency', models.PositiveIntegerField(default=5)),
('email_notifications', models.BooleanField(default=False)),
('webhook_url', models.URLField(blank=True, null=True)),
('cpu_alert_threshold', models.PositiveIntegerField(default=90)),
('memory_alert_threshold', models.PositiveIntegerField(default=90)),
('hdhr_integration', models.BooleanField(default=True)),
('custom_api_endpoints', models.JSONField(blank=True, null=True)),
('backup_path', models.CharField(default='backups/', max_length=255)),
('backup_frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='weekly', max_length=50)),
('ffmpeg_path', models.CharField(default='/usr/bin/ffmpeg', max_length=255)),
('custom_transcoding_flags', models.TextField(blank=True, null=True)),
('celery_worker_concurrency', models.PositiveIntegerField(default=4)),
],
),
]

View file

@ -59,7 +59,7 @@ class EPGGridAPIView(APIView):
start_time__gte=now, start_time__lte=twelve_hours_later
)
count = programs.count()
logger.debug(f"EPGGridAPIView: Found {count} program(s).")
logger.debug(f"EPG`Grid`APIView: Found {count} program(s).")
serializer = ProgramDataSerializer(programs, many=True)
return Response({'data': serializer.data}, status=status.HTTP_200_OK)

View file

@ -0,0 +1,47 @@
# Generated by Django 5.1.6 on 2025-03-02 00:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='EPGData',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('channel_name', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='EPGSource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('source_type', models.CharField(choices=[('xmltv', 'XMLTV URL'), ('schedules_direct', 'Schedules Direct API')], max_length=20)),
('url', models.URLField(blank=True, null=True)),
('api_key', models.CharField(blank=True, max_length=255, null=True)),
('is_active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='ProgramData',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_time', models.DateTimeField()),
('end_time', models.DateTimeField()),
('title', models.CharField(max_length=255)),
('sub_title', models.CharField(blank=True, max_length=255, null=True)),
('description', models.TextField(blank=True, null=True)),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('epg', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='programs', to='epg.epgdata')),
],
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-03-02 00:01
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='HDHRDevice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('friendly_name', models.CharField(default='Dispatcharr HDHomeRun', max_length=100)),
('device_id', models.CharField(max_length=32, unique=True)),
('tuner_count', models.PositiveIntegerField(default=3)),
],
),
]

View file

@ -1,11 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, UserAgentViewSet
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, UserAgentViewSet, M3UAccountProfileViewSet
app_name = 'm3u'
router = DefaultRouter()
router.register(r'accounts', M3UAccountViewSet, basename='m3u-account')
router.register(r'accounts\/(?P<account_id>\d+)\/profiles', M3UAccountProfileViewSet, basename='m3u-account-profiles')
router.register(r'filters', M3UFilterViewSet, basename='m3u-filter')
router.register(r'server-groups', ServerGroupViewSet, basename='server-group')

View file

@ -9,7 +9,7 @@ from django.http import JsonResponse
from django.core.cache import cache
# Import all models, including UserAgent.
from .models import M3UAccount, M3UFilter, ServerGroup
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
from core.serializers import UserAgentSerializer
# Import all serializers, including the UserAgentSerializer.
@ -17,6 +17,7 @@ from .serializers import (
M3UAccountSerializer,
M3UFilterSerializer,
ServerGroupSerializer,
M3UAccountProfileSerializer,
)
from .tasks import refresh_single_m3u_account, refresh_m3u_accounts
@ -68,3 +69,24 @@ class UserAgentViewSet(viewsets.ModelViewSet):
serializer_class = UserAgentSerializer
permission_classes = [IsAuthenticated]
class M3UAccountProfileViewSet(viewsets.ModelViewSet):
queryset = M3UAccountProfile.objects.all()
serializer_class = M3UAccountProfileSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
m3u_account_id = self.kwargs['account_id']
return M3UAccountProfile.objects.filter(m3u_account_id=m3u_account_id)
def perform_create(self, serializer):
# Get the account ID from the URL
account_id = self.kwargs['account_id']
# Get the M3UAccount instance for the account_id
m3u_account = M3UAccount.objects.get(id=account_id)
# Save the 'm3u_account' in the serializer context
serializer.context['m3u_account'] = m3u_account
# Perform the actual save
serializer.save(m3u_account_id=m3u_account)

View file

@ -0,0 +1,65 @@
# Generated by Django 5.1.6 on 2025-03-02 00:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ServerGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Unique name for this server group.', max_length=100, unique=True)),
],
),
migrations.CreateModel(
name='M3UAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Unique name for this M3U account', max_length=255, unique=True)),
('server_url', models.URLField(blank=True, help_text='The base URL of the M3U server (optional if a file is uploaded)', null=True)),
('uploaded_file', models.FileField(blank=True, null=True, upload_to='m3u_uploads/')),
('max_streams', models.PositiveIntegerField(default=0, help_text='Maximum number of concurrent streams (0 for unlimited)')),
('is_active', models.BooleanField(default=True, help_text='Set to false to deactivate this M3U account')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='Time when this account was created')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Time when this account was last updated')),
('user_agent', models.ForeignKey(blank=True, help_text='The User-Agent associated with this M3U account.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='core.useragent')),
('server_group', models.ForeignKey(blank=True, help_text='The server group this M3U account belongs to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='m3u.servergroup')),
],
),
migrations.CreateModel(
name='M3UFilter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('filter_type', models.CharField(choices=[('group', 'Group Title'), ('name', 'Stream Name')], default='group', help_text='Filter based on either group title or stream name.', max_length=50)),
('regex_pattern', models.CharField(help_text='A regex pattern to match streams or groups.', max_length=200)),
('exclude', models.BooleanField(default=True, help_text='If True, matching items are excluded; if False, only matches are included.')),
('m3u_account', models.ForeignKey(help_text='The M3U account this filter is applied to.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='m3u.m3uaccount')),
],
),
migrations.CreateModel(
name='M3UAccountProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name for the M3U account profile', max_length=255)),
('is_default', models.BooleanField(default=False, help_text='Set to false to deactivate this profile')),
('max_streams', models.PositiveIntegerField(default=0, help_text='Maximum number of concurrent streams (0 for unlimited)')),
('is_active', models.BooleanField(default=True, help_text='Set to false to deactivate this profile')),
('search_pattern', models.CharField(max_length=255)),
('replace_pattern', models.CharField(max_length=255)),
('current_viewers', models.PositiveIntegerField(default=0)),
('m3u_account', models.ForeignKey(help_text='The M3U account this profile belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='profiles', to='m3u.m3uaccount')),
],
options={
'constraints': [models.UniqueConstraint(fields=('m3u_account', 'name'), name='unique_account_profile_name')],
},
),
]

View file

@ -2,6 +2,7 @@ from django.db import models
from django.core.exceptions import ValidationError
from core.models import UserAgent
import re
from django.dispatch import receiver
class M3UAccount(models.Model):
"""Represents an M3U Account for IPTV streams."""
@ -134,7 +135,7 @@ class M3UFilter(models.Model):
# If no include filters exist, assume all non-excluded streams are valid
if not any(not f.exclude for f in filters):
return streams.exclude(id__in=[s.id for s in excluded_streams])
return streams.filter(id__in=[s.id for s in included_streams])
@ -148,3 +149,68 @@ class ServerGroup(models.Model):
def __str__(self):
return self.name
from django.db import models
class M3UAccountProfile(models.Model):
"""Represents a profile associated with an M3U Account."""
m3u_account = models.ForeignKey(
'M3UAccount',
on_delete=models.CASCADE,
related_name='profiles',
help_text="The M3U account this profile belongs to."
)
name = models.CharField(
max_length=255,
help_text="Name for the M3U account profile"
)
is_default = models.BooleanField(
default=False,
help_text="Set to false to deactivate this profile"
)
max_streams = models.PositiveIntegerField(
default=0,
help_text="Maximum number of concurrent streams (0 for unlimited)"
)
is_active = models.BooleanField(
default=True,
help_text="Set to false to deactivate this profile"
)
search_pattern = models.CharField(
max_length=255,
)
replace_pattern = models.CharField(
max_length=255,
)
current_viewers = models.PositiveIntegerField(default=0)
class Meta:
constraints = [
models.UniqueConstraint(fields=['m3u_account', 'name'], name='unique_account_profile_name')
]
def __str__(self):
return f"{self.name} ({self.m3u_account.name})"
@receiver(models.signals.post_save, sender=M3UAccount)
def create_profile_for_m3u_account(sender, instance, created, **kwargs):
"""Automatically create an M3UAccountProfile when M3UAccount is created."""
if created:
M3UAccountProfile.objects.create(
m3u_account=instance,
name=f'{instance.name} Default',
max_streams=instance.max_streams,
is_default=True,
is_active=True,
search_pattern="^(.*)$",
replace_pattern="$1",
)
else:
profile = M3UAccountProfile.objects.get(
m3u_account=instance,
is_default=True,
)
profile.max_streams = instance.max_streams
profile.save()

View file

@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import M3UAccount, M3UFilter, ServerGroup
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
class M3UFilterSerializer(serializers.ModelSerializer):
@ -9,6 +9,23 @@ class M3UFilterSerializer(serializers.ModelSerializer):
model = M3UFilter
fields = ['id', 'filter_type', 'regex_pattern', 'exclude']
from rest_framework import serializers
from .models import M3UAccountProfile
class M3UAccountProfileSerializer(serializers.ModelSerializer):
class Meta:
model = M3UAccountProfile
fields = ['id', 'name', 'max_streams', 'is_active', 'is_default', 'current_viewers', 'search_pattern', 'replace_pattern']
read_only_fields = ['id']
def create(self, validated_data):
m3u_account = self.context.get('m3u_account')
# Use the m3u_account when creating the profile
validated_data['m3u_account_id'] = m3u_account.id
return super().create(validated_data)
class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
@ -18,20 +35,18 @@ class M3UAccountSerializer(serializers.ModelSerializer):
queryset=UserAgent.objects.all(),
required=True
)
profiles = M3UAccountProfileSerializer(many=True, read_only=True)
class Meta:
model = M3UAccount
fields = [
'id', 'name', 'server_url', 'uploaded_file', 'server_group',
'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent'
'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent', 'profiles'
]
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""
class Meta:
model = ServerGroup
fields = ['id', 'name']

View file

@ -36,34 +36,6 @@ class CoreSettingsAdmin(admin.ModelAdmin):
just list and allow editing of any instance.
"""
list_display = (
"default_user_agent",
"default_stream_profile",
"stream_command_timeout",
"enable_stream_logging",
"useragent_cache_timeout",
"streamprofile_cache_timeout",
"streamlink_path",
"vlc_path",
)
fieldsets = (
(None, {
"fields": (
"default_user_agent",
"default_stream_profile",
"stream_command_timeout",
"enable_stream_logging",
)
}),
("Caching", {
"fields": (
"useragent_cache_timeout",
"streamprofile_cache_timeout",
)
}),
("Paths", {
"fields": (
"streamlink_path",
"vlc_path",
)
}),
"key",
"value",
)

View file

@ -28,27 +28,3 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
"""
queryset = CoreSettings.objects.all()
serializer_class = CoreSettingsSerializer
permission_classes = [IsAuthenticated]
def create(self, request, *args, **kwargs):
if CoreSettings.objects.exists():
return Response(
{"detail": "Core settings already exist. Use PUT to update."},
status=status.HTTP_400_BAD_REQUEST
)
return super().create(request, *args, **kwargs)
def list(self, request, *args, **kwargs):
# Always return the singleton instance (creating it if needed)
settings_instance, created = CoreSettings.objects.get_or_create(pk=1)
serializer = self.get_serializer(settings_instance)
return Response([serializer.data]) # Return as a list for DRF router compatibility
def retrieve(self, request, *args, **kwargs):
# Retrieve the singleton instance
settings_instance = get_object_or_404(CoreSettings, pk=1)
serializer = self.get_serializer(settings_instance)
return Response(serializer.data)

View file

@ -0,0 +1,44 @@
[
{
"model": "core.useragent",
"pk": 1,
"fields": {
"user_agent_name": "TiviMate",
"user_agent": "TiviMate/5.1.6 (Android 12)",
"description": "",
"is_active": true
}
},
{
"model": "core.useragent",
"pk": 2,
"fields": {
"user_agent_name": "VLC",
"user_agent": "VLC/3.0.21 LibVLC 3.0.21",
"description": "",
"is_active": true
}
},
{
"model": "core.streamprofile",
"pk": 1,
"fields": {
"profile_name": "ffmpeg",
"command": "ffmpeg",
"parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1",
"is_active": true,
"user_agent": "1"
}
},
{
"model": "core.streamprofile",
"pk": 2,
"fields": {
"profile_name": "streamlink",
"command": "streamlink",
"parameters": "{streamUrl} best --stdout",
"is_active": true,
"user_agent": "1"
}
}
]

View file

@ -0,0 +1,27 @@
# core/management/commands/kill_processes.py
import psutil
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Kills all processes with 'ffmpeg' or 'streamlink' in their name or command line."
def handle(self, *args, **options):
kill_count = 0
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
name = proc.info.get('name') or ''
cmdline = ' '.join(proc.info.get('cmdline') or [])
lower_name = name.lower()
lower_cmdline = cmdline.lower()
if ('ffmpeg' in lower_name or 'ffmpeg' in lower_cmdline or
'streamlink' in lower_name or 'streamlink' in lower_cmdline):
self.stdout.write(f"Killing PID {proc.pid}: {name} {cmdline}")
proc.kill()
kill_count += 1
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
self.stdout.write(self.style.SUCCESS(f"Killed {kill_count} processes."))

View file

@ -0,0 +1,46 @@
# Generated by Django 5.1.6 on 2025-03-02 00:01
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='CoreSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255, unique=True)),
('name', models.CharField(max_length=255)),
('value', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='StreamProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('profile_name', models.CharField(help_text='Name of the stream profile', max_length=255)),
('command', models.CharField(help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')", max_length=255)),
('parameters', models.TextField(help_text='Command-line parameters. Use {userAgent} and {streamUrl} as placeholders.')),
('is_active', models.BooleanField(default=True, help_text='Whether this profile is active')),
('user_agent', models.CharField(blank=True, help_text='Optional user agent to use. If not set, you can fall back to a default.', max_length=512, null=True)),
],
),
migrations.CreateModel(
name='UserAgent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_agent_name', models.CharField(help_text='The User-Agent name.', max_length=512, unique=True)),
('user_agent', models.CharField(help_text='The complete User-Agent string sent by the client.', max_length=512, unique=True)),
('description', models.CharField(blank=True, help_text='An optional description of the client or device type.', max_length=255)),
('is_active', models.BooleanField(default=True, help_text='Whether this user agent is currently allowed/recognized.')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
]

View file

@ -0,0 +1,36 @@
# Generated by Django 5.1.6 on 2025-03-01 14:01
from django.db import migrations
def preload_user_agent(apps, schema_editor):
UserAgent = apps.get_model("core", "UserAgent")
UserAgent.objects.create(
user_agent_name="TiviMate",
user_agent="TiviMate/5.16 (Android 12)",
description="",
is_active=True,
)
UserAgent.objects.create(
user_agent_name="VLC",
user_agent="VLC/3.0.21 LibVLC/3.0.21",
description="",
is_active=True,
)
UserAgent.objects.create(
user_agent_name="Chrome",
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.3",
description="",
is_active=True,
)
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.RunPython(preload_user_agent),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-03-01 14:01
from django.db import migrations
def preload_stream_profiles(apps, schema_editor):
StreamProfile = apps.get_model("core", "StreamProfile")
StreamProfile.objects.create(
profile_name="ffmpeg",
command="ffmpeg",
parameters="-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1",
is_active=True,
user_agent="1",
)
StreamProfile.objects.create(
profile_name="streamlink",
command="streamlink",
parameters="{streamUrl} best --stdout",
is_active=True,
user_agent="1",
)
class Migration(migrations.Migration):
dependencies = [
('core', '0002_preload_user_agents'),
]
operations = [
migrations.RunPython(preload_stream_profiles),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 5.1.6 on 2025-03-01 14:01
from django.db import migrations
from django.utils.text import slugify
def preload_core_settings(apps, schema_editor):
CoreSettings = apps.get_model("core", "CoreSettings")
CoreSettings.objects.create(
key=slugify("Default Stream Profile"),
name="Default Stream Profile",
value=1,
)
CoreSettings.objects.create(
key=slugify("Default User-Agent"),
name="Default User-Agent",
value=1,
)
class Migration(migrations.Migration):
dependencies = [
('core', '0003_preload_stream_profiles'),
]
operations = [
migrations.RunPython(preload_core_settings),
]

View file

@ -49,46 +49,16 @@ class StreamProfile(models.Model):
class CoreSettings(models.Model):
default_user_agent = models.CharField(
max_length=512,
default="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/112.0.0.0 Safari/537.36",
help_text="The default User-Agent string to use if none is provided."
)
default_stream_profile = models.CharField(
key = models.CharField(
max_length=255,
default="default_profile",
help_text="Name or identifier for the default stream profile."
unique=True,
)
stream_command_timeout = models.PositiveIntegerField(
default=300,
help_text="Timeout in seconds for running stream commands."
)
enable_stream_logging = models.BooleanField(
default=True,
help_text="Toggle verbose logging for stream commands."
)
useragent_cache_timeout = models.PositiveIntegerField(
default=300,
help_text="Cache timeout in seconds for user agent data."
)
streamprofile_cache_timeout = models.PositiveIntegerField(
default=300,
help_text="Cache timeout in seconds for stream profile data."
)
streamlink_path = models.CharField(
name = models.CharField(
max_length=255,
default="/usr/bin/streamlink",
help_text="Override path for the streamlink command."
)
vlc_path = models.CharField(
value = models.CharField(
max_length=255,
default="/usr/bin/vlc",
help_text="Override path for the VLC command."
)
def __str__(self):
return "Core Settings"
class Meta:
verbose_name = "Core Setting"
verbose_name_plural = "Core Settings"

View file

@ -1,20 +1,27 @@
# core/views.py
import os
import sys
import subprocess
import logging
import re
import redis
from django.conf import settings
from django.http import StreamingHttpResponse, HttpResponseServerError
from django.db.models import F
from django.shortcuts import render
from apps.channels.models import Channel, Stream
from core.models import StreamProfile
from apps.m3u.models import M3UAccountProfile
from core.models import StreamProfile, CoreSettings
# Import the persistent lock (the “real” lock)
from dispatcharr.persistent_lock import PersistentLock
# Configure logging to output to the console.
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logger = logging.getLogger(__name__)
def settings_view(request):
"""
Renders the settings page.
@ -26,6 +33,7 @@ def stream_view(request, stream_id):
"""
Streams the first available stream for the given channel.
It uses the channels assigned StreamProfile.
A persistent Redis lock is used to prevent concurrent streaming on the same channel.
"""
try:
# Retrieve the channel by the provided stream_id.
@ -41,41 +49,86 @@ def stream_view(request, stream_id):
stream = channel.streams.first()
logger.debug("Using stream: ID=%s, Name=%s", stream.id, stream.name)
# Retrieve the M3U account associated with the stream.
m3u_account = stream.m3u_account
logger.debug("Using M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name)
# Use the custom URL if available; otherwise, use the standard URL.
input_url = stream.custom_url or stream.url
logger.debug("Input URL: %s", input_url)
# Determine which profile we can use.
m3u_profiles = m3u_account.profiles.all()
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
profiles = [obj for obj in m3u_profiles if not obj.is_default]
active_profile = None
# -- Loop through profiles and pick the first active one --
for profile in [default_profile] + profiles:
logger.debug(f'Checking profile {profile.name}...')
if not profile.is_active:
logger.debug('Profile is not active, skipping.')
continue
# *** DISABLE FAKE LOCKS: Ignore current_viewers/max_streams check ***
logger.debug(f"Using M3U profile ID={profile.id} (ignoring viewer count limits)")
active_profile = M3UAccountProfile.objects.get(id=profile.id)
# Prepare the pattern replacement.
logger.debug("Executing the following pattern replacement:")
logger.debug(f" search: {profile.search_pattern}")
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern)
logger.debug(f" replace: {profile.replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
stream_url = re.sub(profile.search_pattern, safe_replace_pattern, input_url)
logger.debug(f"Generated stream url: {stream_url}")
break
if active_profile is None:
logger.exception("No available profiles for the stream")
return HttpResponseServerError("No available profiles for the stream")
# Get the stream profile set on the channel.
# (Ensure your Channel model has a 'stream_profile' field.)
profile = channel.stream_profile
if not profile:
logger.error("No stream profile set for channel ID=%s", channel.id)
return HttpResponseServerError("No stream profile set for this channel.")
logger.debug("Stream profile used: %s", profile.profile_name)
stream_profile = channel.stream_profile
if not stream_profile:
logger.error("No stream profile set for channel ID=%s, using default", channel.id)
stream_profile = StreamProfile.objects.get(id=CoreSettings.objects.get(key="default-stream-profile").value)
logger.debug("Stream profile used: %s", stream_profile.profile_name)
# Determine the user agent to use.
user_agent = profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
logger.debug("User agent: %s", user_agent)
# Substitute placeholders in the parameters template.
parameters = profile.parameters.format(userAgent=user_agent, streamUrl=input_url)
parameters = stream_profile.parameters.format(userAgent=user_agent, streamUrl=stream_url)
logger.debug("Formatted parameters: %s", parameters)
# Build the final command.
cmd = [profile.command] + parameters.split()
cmd = [stream_profile.command] + parameters.split()
logger.debug("Executing command: %s", cmd)
# Increment the viewer count.
Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') + 1)
logger.debug("Viewer count incremented for stream ID=%s", stream.id)
# Acquire the persistent Redis lock.
redis_host = getattr(settings, "REDIS_HOST", "localhost")
redis_client = redis.Redis(host=settings.REDIS_HOST, port=6379, db=0)
lock_key = f"lock:channel:{channel.id}"
persistent_lock = PersistentLock(redis_client, lock_key, lock_timeout=120)
if not persistent_lock.acquire():
logger.error("Could not acquire persistent lock for channel %s", channel.id)
return HttpResponseServerError("Resource busy, please try again later.")
try:
# Start the streaming process.
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except Exception as e:
persistent_lock.release() # Ensure the lock is released on error.
logger.exception("Error starting stream for channel ID=%s", stream_id)
return HttpResponseServerError(f"Error starting stream: {e}")
# Start the streaming process.
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except Exception as e:
logger.exception("Error starting stream for channel ID=%s", stream_id)
return HttpResponseServerError(f"Error starting stream: {e}")
logger.exception("Error preparing stream for channel ID=%s", stream_id)
return HttpResponseServerError(f"Error preparing stream: {e}")
def stream_generator(proc, s):
def stream_generator(proc, s, persistent_lock):
try:
while True:
chunk = proc.stdout.read(8192)
@ -83,8 +136,15 @@ def stream_view(request, stream_id):
break
yield chunk
finally:
# Decrement the viewer count once streaming ends.
Stream.objects.filter(id=s.id).update(current_viewers=F('current_viewers') - 1)
logger.debug("Viewer count decremented for stream ID=%s", s.id)
try:
proc.terminate()
logger.debug("Streaming process terminated for stream ID=%s", s.id)
except Exception as e:
logger.error("Error terminating process for stream ID=%s: %s", s.id, e)
persistent_lock.release()
logger.debug("Persistent lock released for channel ID=%s", channel.id)
return StreamingHttpResponse(stream_generator(process, stream), content_type="video/MP2T")
return StreamingHttpResponse(
stream_generator(process, stream, persistent_lock),
content_type="video/MP2T"
)

View file

@ -0,0 +1,84 @@
# dispatcharr/persistent_lock.py
import uuid
import redis
class PersistentLock:
"""
A persistent, auto-expiring lock that uses Redis.
Usage:
1. Instantiate with a Redis client, a unique lock key (e.g. "lock:account:123"),
and an optional timeout (in seconds).
2. Call acquire() to try to obtain the lock.
3. Optionally, periodically call refresh() to extend the lock's lifetime.
4. When finished, call release() to free the lock.
"""
def __init__(self, redis_client: redis.Redis, lock_key: str, lock_timeout: int = 120):
"""
Initialize the lock.
:param redis_client: An instance of redis.Redis.
:param lock_key: The unique key for the lock.
:param lock_timeout: Time-to-live for the lock in seconds.
"""
self.redis_client = redis_client
self.lock_key = lock_key
self.lock_timeout = lock_timeout
self.lock_token = None
def acquire(self) -> bool:
"""
Attempt to acquire the lock. Returns True if successful.
"""
self.lock_token = str(uuid.uuid4())
# Set the lock with NX (only if not exists) and EX (expire time)
result = self.redis_client.set(self.lock_key, self.lock_token, nx=True, ex=self.lock_timeout)
return result is not None
def refresh(self) -> bool:
"""
Refresh the lock's expiration time if this instance owns the lock.
Returns True if the expiration was successfully extended.
"""
current_value = self.redis_client.get(self.lock_key)
if current_value and current_value.decode("utf-8") == self.lock_token:
self.redis_client.expire(self.lock_key, self.lock_timeout)
return True
return False
def release(self) -> bool:
"""
Release the lock only if owned by this instance.
Returns True if the lock was successfully released.
"""
# Use a Lua script for atomicity: only delete if the token matches.
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
release_lock = self.redis_client.register_script(lua_script)
result = release_lock(keys=[self.lock_key], args=[self.lock_token])
return result == 1
# Example usage (for testing purposes only):
if __name__ == "__main__":
# Connect to Redis on localhost; adjust connection parameters as needed.
client = redis.Redis(host="localhost", port=6379, db=0)
lock = PersistentLock(client, "lock:example_account", lock_timeout=120)
if lock.acquire():
print("Lock acquired successfully!")
# Do work here...
# Optionally refresh the lock periodically:
if lock.refresh():
print("Lock refreshed.")
# Finally, release the lock:
if lock.release():
print("Lock released.")
else:
print("Failed to release lock.")
else:
print("Failed to acquire lock.")

View file

@ -5,6 +5,7 @@ from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'REPLACE_ME_WITH_A_REAL_SECRET'
REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
DEBUG = True
ALLOWED_HOSTS = ["*"]
@ -68,16 +69,24 @@ TEMPLATES = [
WSGI_APPLICATION = 'dispatcharr.wsgi.application'
ASGI_APPLICATION = 'dispatcharr.asgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('POSTGRES_DB', 'dispatcharr'),
'USER': os.environ.get('POSTGRES_USER', 'dispatch'),
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'secret'),
'HOST': os.environ.get('POSTGRES_HOST', 'localhost'),
'PORT': int(os.environ.get('POSTGRES_PORT', 5432)),
if os.getenv('DB_ENGINE', None) == 'sqlite':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': '/data/dispatcharr.db',
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('POSTGRES_DB', 'dispatcharr'),
'USER': os.environ.get('POSTGRES_USER', 'dispatch'),
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'secret'),
'HOST': os.environ.get('POSTGRES_HOST', 'localhost'),
'PORT': int(os.environ.get('POSTGRES_PORT', 5432)),
}
}
}
AUTH_PASSWORD_VALIDATORS = [
{

View file

@ -1,37 +1,54 @@
FROM python:3.10-slim
FROM alpine
# Install required packages including ffmpeg, streamlink, and vlc
RUN apt-get update && apt-get install -y \
ffmpeg \
streamlink \
vlc \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/dispatcharrpy/bin:$PATH" \
VIRTUAL_ENV=/dispatcharrpy \
DJANGO_SETTINGS_MODULE=dispatcharr.settings \
PYTHONUNBUFFERED=1
RUN apk add \
python3 \
python3-dev \
gcc \
musl-dev \
linux-headers \
py3-pip \
ffmpeg \
streamlink \
vlc \
libpq-dev \
gcc \
py3-virtualenv \
uwsgi \
uwsgi-python \
nodejs \
npm \
git \
redis
RUN \
mkdir /data && \
virtualenv /dispatcharrpy && \
git clone https://github.com/Dispatcharr/Dispatcharr /app && \
cd /app && \
/dispatcharrpy/bin/pip install --no-cache-dir -r requirements.txt && \
cd /app/frontend && \
npm install && \
npm run build && \
find . -maxdepth 1 ! -name '.' ! -name 'build' -exec rm -rf '{}' \; && \
cd /app && \
python manage.py collectstatic --noinput || true
# Cleanup
RUN \
apk del \
nodejs \
npm \
git \
gcc \
musl-dev \
python3-dev \
linux-headers
# Set the working directory
WORKDIR /app
ENV API_PORT=5656
# Install Python dependencies
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# Copy application files
COPY . /app/
# Set environment variables
ENV DJANGO_SETTINGS_MODULE=dispatcharr.settings
ENV PYTHONUNBUFFERED=1
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
# Run Django commands
RUN python manage.py collectstatic --noinput || true
RUN python manage.py migrate --noinput || true
# Expose port 9191 (this is the port the app will listen on inside the container)
EXPOSE 9191
# Command to run the application binding to host and port
CMD ["gunicorn", "--workers=4", "--worker-class=gevent", "--timeout=300", "--bind", "0.0.0.0:5656", "dispatcharr.wsgi:application"]
CMD ["/app/docker/entrypoint.aio.sh"]

View file

@ -1,5 +1,7 @@
FROM python:3.10-slim
ENV API_PORT=5656
# Add PostgreSQL repository
RUN apt-get update && apt-get install -y wget gnupg2 && \
echo "deb http://apt.postgresql.org/pub/repos/apt/ bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \

3
docker/build-dev.sh Normal file
View file

@ -0,0 +1,3 @@
#!/bin/bash
docker build -t dispatcharr/dispatcharr:dev ..

View file

@ -1,24 +0,0 @@
services:
dispatcharr:
build:
context: ..
dockerfile: docker/DockerfileAIO
container_name: dispatcharr-dev
restart: unless-stopped
ports:
- 9191:9191
#- 5432:5432
environment:
- PUID=1000
- PGID=1000
- POSTGRES_HOST=localhost
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
- POSTGRES_PORT=5432
- POSTGRES_DB=dispatcharr
- DJANGO_SUPERUSER_USERNAME=admin
- DJANGO_SUPERUSER_PASSWORD=admin
- DJANGO_SUPERUSER_EMAIL=admin@dispatcharr.local
volumes:
- ./data:/app/data

View file

@ -0,0 +1,19 @@
services:
dispatcharr:
# build:
# context: ..
# dockerfile: docker/Dockerfile.alpine
image: dispatcharr/dispatcharr
container_name: dispatcharr
ports:
- 9191:9191
volumes:
- dispatcharr:/data
environment:
- DISPATHCARR_ENV=aio
- DB_ENGINE=sqlite
- REDIS_HOST=localhost
- CELERY_BROKER_URL=redis://localhost:6379/0
volumes:
dispatcharr:

View file

@ -0,0 +1,17 @@
services:
dispatcharr:
# build:
# context: ..
# dockerfile: docker/Dockerfile.dev
image: dispatcharr/dispatcharr
container_name: dispatcharr_dev
ports:
- "5656:5656"
- 9191:9191
volumes:
- /home/ghost/code/Dispatcharr:/app
environment:
- DISPATCHARR_ENV=dev
- DB_ENGINE=sqlite
- REDIS_HOST=localhost
- CELERY_BROKER_URL=redis://localhost:6379/0

View file

@ -1,19 +1,12 @@
services:
web:
build:
context: ..
dockerfile: docker/Dockerfile
image: dispatcharr/dispatcharr
container_name: dispatcharr_web
ports:
- "9191:9191"
- "5656:5656"
- 9191:9191
depends_on:
- db
- redis
volumes:
- ../:/app
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- POSTGRES_HOST=db
- POSTGRES_DB=dispatcharr
@ -21,15 +14,6 @@ services:
- POSTGRES_PASSWORD=secret
- REDIS_HOST=redis
- CELERY_BROKER_URL=redis://redis:6379/0
- REACT_UI=true
ui:
image: alpine
container_name: dispatcharr_ui
network_mode: service:web
volumes:
- ../frontend:/app
entrypoint: ["/bin/sh", "/app/entrypoint.sh"]
celery:
build:
@ -44,7 +28,7 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- POSTGRES_HOST=dispatcharr_db
- POSTGRES_HOST=db
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
@ -65,8 +49,8 @@ services:
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
# volumes:
# - postgres_data:/var/lib/postgresql/data
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:latest

20
docker/entrypoint.aio.sh Executable file
View file

@ -0,0 +1,20 @@
#!/bin/sh
# Check the value of DISPATCHARR_ENV and run the corresponding program
case "$DISPATCHARR_ENV" in
"dev")
echo "DISPATCHARR_ENV is set to 'dev'. Running Development Program..."
apk add nodejs npm
cd /app/frontend && npm install
cd /app
exec /usr/sbin/uwsgi --ini uwsgi.dev.ini
;;
"aio")
echo "DISPATCHARR_ENV is set to 'aio'. Running All-in-One Program..."
exec /usr/sbin/uwsgi --ini uwsgi.aio.ini
;;
*)
echo "DISPATCHARR_ENV is not set or has an unexpected value. Running standalone..."
exec /usr/sbin/uwsgi --ini uwsgi.ini
;;
esac

72
fixtures.json Normal file
View file

@ -0,0 +1,72 @@
[
{
"model": "core.useragent",
"fields": {
"user_agent_name": "TiviMate",
"user_agent": "TiviMate/5.16 (Android 12)",
"description": "",
"is_active": true,
"created_at": "2025-02-28T20:35:14.668Z",
"updated_at": "2025-02-28T20:35:14.668Z"
}
},
{
"model": "core.useragent",
"fields": {
"user_agent_name": "VLC",
"user_agent": "VLC/3.0.21 LibVLC/3.0.21",
"description": "",
"is_active": true,
"created_at": "2025-02-28T20:35:14.668Z",
"updated_at": "2025-02-28T20:35:14.668Z"
}
},
{
"model": "core.useragent",
"fields": {
"user_agent_name": "Chrome",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.3",
"description": "",
"is_active": true,
"created_at": "2025-02-28T20:35:14.668Z",
"updated_at": "2025-02-28T20:35:14.668Z"
}
},
{
"model": "core.streamprofile",
"pk": 1,
"fields": {
"profile_name": "ffmpeg",
"command": "ffmpeg",
"parameters": "-i {streamUrl} -c:a copy -c:v copy -f mpegts pipe:1",
"is_active": true,
"user_agent": "1"
}
},
{
"model": "core.streamprofile",
"fields": {
"profile_name": "streamlink",
"command": "streamlink",
"parameters": "{streamUrl} best --stdout",
"is_active": true,
"user_agent": "1"
}
},
{
"model": "core.coresettings",
"fields": {
"key": "default-user-agent",
"name": "Default User-Agent",
"value": "1"
}
},
{
"model": "core.coresettings",
"fields": {
"key": "default-stream-profile",
"name": "Default Stream Profile",
"value": "1"
}
}
]

View file

@ -13,10 +13,12 @@
"@fontsource/roboto": "^5.1.1",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"@videojs/http-streaming": "^3.17.0",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"eslint": "^8.57.1",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"material-react-table": "^3.2.0",
"mpegts.js": "^1.4.2",
"planby": "^1.1.7",
@ -27,6 +29,7 @@
"react-router-dom": "^7.2.0",
"react-scripts": "5.0.1",
"react-window": "^1.8.11",
"video.js": "^8.21.0",
"web-vitals": "^2.1.4",
"yup": "^1.6.1",
"zustand": "^5.0.3"
@ -4441,6 +4444,54 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@videojs/http-streaming": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.0.tgz",
"integrity": "sha512-Ch1P3tvvIEezeZXyK11UfWgp4cWKX4vIhZ30baN/lRinqdbakZ5hiAI3pGjRy3d+q/Epyc8Csz5xMdKNNGYpcw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.1.1",
"aes-decrypter": "^4.0.2",
"global": "^4.4.0",
"m3u8-parser": "^7.2.0",
"mpd-parser": "^1.3.1",
"mux.js": "7.1.0",
"video.js": "^7 || ^8"
},
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"video.js": "^8.19.0"
}
},
"node_modules/@videojs/vhs-utils": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz",
"integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"global": "^4.4.0"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/@videojs/xhr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz",
"integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.5.5",
"global": "~4.4.0",
"is-function": "^1.0.1"
}
},
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@ -4572,6 +4623,15 @@
"@xtuc/long": "4.2.2"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@ -4676,6 +4736,18 @@
"node": ">=8.9"
}
},
"node_modules/aes-decrypter": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
"integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.1.1",
"global": "^4.4.0",
"pkcs7": "^1.0.4"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -6831,6 +6903,11 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@ -8677,6 +8754,16 @@
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="
},
"node_modules/global": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"license": "MIT",
"dependencies": {
"min-document": "^2.19.0",
"process": "^0.11.10"
}
},
"node_modules/global-modules": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
@ -8897,6 +8984,12 @@
"npm": ">= 9"
}
},
"node_modules/hls.js": {
"version": "1.5.20",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz",
"integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==",
"license": "Apache-2.0"
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@ -9479,6 +9572,12 @@
"node": ">=8"
}
},
"node_modules/is-function": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
"license": "MIT"
},
"node_modules/is-generator-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
@ -11112,6 +11211,17 @@
"yallist": "^3.0.2"
}
},
"node_modules/m3u8-parser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
"integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.1.1",
"global": "^4.4.0"
}
},
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@ -11295,6 +11405,14 @@
"node": ">=6"
}
},
"node_modules/min-document": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
"integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
"dependencies": {
"dom-walk": "^0.1.0"
}
},
"node_modules/mini-css-extract-plugin": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz",
@ -11357,6 +11475,21 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mpd-parser": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz",
"integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.0.0",
"@xmldom/xmldom": "^0.8.3",
"global": "^4.4.0"
},
"bin": {
"mpd-to-m3u8-json": "bin/parse.js"
}
},
"node_modules/mpegts.js": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/mpegts.js/-/mpegts.js-1.8.0.tgz",
@ -11384,6 +11517,23 @@
"multicast-dns": "cli.js"
}
},
"node_modules/mux.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz",
"integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.11.2",
"global": "^4.4.0"
},
"bin": {
"muxjs-transmux": "bin/transmux.js"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@ -11955,6 +12105,18 @@
"node": ">= 6"
}
},
"node_modules/pkcs7": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.5.5"
},
"bin": {
"pkcs7": "bin/cli.js"
}
},
"node_modules/pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@ -13345,6 +13507,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@ -16301,6 +16472,57 @@
"node": ">= 0.8"
}
},
"node_modules/video.js": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.21.0.tgz",
"integrity": "sha512-zcwerRb257QAuWfi8NH9yEX7vrGKFthjfcONmOQ4lxFRpDAbAi+u5LAjCjMWqhJda6zEmxkgdDpOMW3Y21QpXA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/http-streaming": "^3.16.2",
"@videojs/vhs-utils": "^4.1.1",
"@videojs/xhr": "2.7.0",
"aes-decrypter": "^4.0.2",
"global": "4.4.0",
"m3u8-parser": "^7.2.0",
"mpd-parser": "^1.3.1",
"mux.js": "^7.0.1",
"videojs-contrib-quality-levels": "4.1.0",
"videojs-font": "4.2.0",
"videojs-vtt.js": "0.15.5"
}
},
"node_modules/videojs-contrib-quality-levels": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
"integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
"license": "Apache-2.0",
"dependencies": {
"global": "^4.4.0"
},
"engines": {
"node": ">=16",
"npm": ">=8"
},
"peerDependencies": {
"video.js": "^8"
}
},
"node_modules/videojs-font": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz",
"integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==",
"license": "Apache-2.0"
},
"node_modules/videojs-vtt.js": {
"version": "0.15.5",
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
"integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==",
"license": "Apache-2.0",
"dependencies": {
"global": "^4.3.1"
}
},
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",

View file

@ -8,10 +8,12 @@
"@fontsource/roboto": "^5.1.1",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"@videojs/http-streaming": "^3.17.0",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"eslint": "^8.57.1",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"material-react-table": "^3.2.0",
"mpegts.js": "^1.4.2",
"planby": "^1.1.7",
@ -22,6 +24,7 @@
"react-router-dom": "^7.2.0",
"react-scripts": "5.0.1",
"react-window": "^1.8.11",
"video.js": "^8.21.0",
"web-vitals": "^2.1.4",
"yup": "^1.6.1",
"zustand": "^5.0.3"
@ -51,5 +54,5 @@
"last 1 safari version"
]
},
"proxy": "http://127.0.0.1:5656"
"proxy": "http://host.docker.internal:5656"
}

View file

@ -24,6 +24,7 @@ import {
import theme from './theme';
import EPG from './pages/EPG';
import Guide from './pages/Guide';
import Settings from './pages/Settings';
import StreamProfiles from './pages/StreamProfiles';
import useAuthStore from './store/auth';
import logo from './images/logo.png';
@ -105,11 +106,11 @@ const App = () => {
<Box
sx={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
// height: '100vh',
backgroundColor: '#495057',
}}
>
@ -127,11 +128,9 @@ const App = () => {
<Route path="/channels" element={<Channels />} />
<Route path="/m3u" element={<M3U />} />
<Route path="/epg" element={<EPG />} />
<Route
path="/stream-profiles"
element={<StreamProfiles />}
/>
<Route path="/stream-profiles" element={<StreamProfiles />} />
<Route path="/guide" element={<Guide />} />
<Route path="/settings" element={<Settings />} />
</>
) : (
<Route path="/login" element={<Login />} />

View file

@ -5,6 +5,7 @@ import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
import useStreamsStore from './store/streams';
import useStreamProfilesStore from './store/streamProfiles';
import useSettingsStore from './store/settings';
// const axios = Axios.create({
// withCredentials: true,
@ -12,7 +13,7 @@ import useStreamProfilesStore from './store/streamProfiles';
const host = '';
const getAuthToken = async () => {
export const getAuthToken = async () => {
const token = await useAuthStore.getState().getToken(); // Assuming token is stored in Zustand store
return token;
};
@ -157,18 +158,18 @@ export default class API {
}
// @TODO: the bulk delete endpoint is currently broken
// static async deleteChannels(channel_ids) {
// const response = await fetch(`${host}/api/channels/bulk-delete-channels/0/`, {
// method: 'DELETE',
// headers: {
// Authorization: `Bearer ${await getAuthToken()}`,
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({ channel_ids }),
// });
static async deleteChannels(channel_ids) {
const response = await fetch(`${host}/api/channels/channels/bulk-delete/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel_ids }),
});
// useChannelsStore.getState().removeChannels(channel_ids)
// }
useChannelsStore.getState().removeChannels(channel_ids);
}
static async updateChannel(values) {
const { id, ...payload } = values;
@ -189,21 +190,31 @@ export default class API {
return retval;
}
static async assignChannelNumbers(ids) {
static async assignChannelNumbers(channelIds) {
// Make the request
const response = await fetch(`${host}/api/channels/channels/assign/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel_order: ids }),
body: JSON.stringify({ channel_order: channelIds }),
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannel(retval);
// The backend returns something like { "message": "Channels have been auto-assigned!" }
if (!response.ok) {
// If you want to handle errors gracefully:
const text = await response.text();
throw new Error(`Assign channels failed: ${response.status} => ${text}`);
}
// Usually it has a { message: "..."} or similar
const retval = await response.json();
// If you want to automatically refresh the channel list in Zustand:
await useChannelsStore.getState().fetchChannels();
// Return the entire JSON result (so the caller can see the "message")
return retval;
}
@ -225,6 +236,27 @@ export default class API {
return retval;
}
static async createChannelsFromStreams(values) {
const response = await fetch(
`${host}/api/channels/channels/from-stream/bulk/`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
}
);
const retval = await response.json();
if (retval.created.length > 0) {
useChannelsStore.getState().addChannels(retval.created);
}
return retval;
}
static async getStreams() {
const response = await fetch(`${host}/api/channels/streams/`, {
headers: {
@ -360,6 +392,18 @@ export default class API {
useUserAgentsStore.getState().removeUserAgents([id]);
}
static async getPlaylist(id) {
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
static async getPlaylists() {
const response = await fetch(`${host}/api/m3u/accounts/`, {
headers: {
@ -603,4 +647,94 @@ export default class API {
const retval = await response.json();
return retval.data;
}
static async addM3UProfile(accountId, values) {
const response = await fetch(
`${host}/api/m3u/accounts/${accountId}/profiles/`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
}
);
const retval = await response.json();
if (retval.id) {
// Fetch m3u account to update it with its new playlists
const playlist = await API.getPlaylist(accountId);
usePlaylistsStore
.getState()
.updateProfiles(playlist.id, playlist.profiles);
}
return retval;
}
static async deleteM3UProfile(accountId, id) {
const response = await fetch(
`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
}
);
const playlist = await API.getPlaylist(accountId);
usePlaylistsStore.getState().updatePlaylist(playlist);
}
static async updateM3UProfile(accountId, values) {
const { id, ...payload } = values;
const response = await fetch(
`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
);
const playlist = await API.getPlaylist(accountId);
usePlaylistsStore.getState().updateProfiles(playlist.id, playlist.profiles);
}
static async getSettings() {
const response = await fetch(`${host}/api/core/settings/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
},
});
const retval = await response.json();
return retval;
}
static async updateSetting(values) {
const { id, ...payload } = values;
const response = await fetch(`${host}/api/core/settings/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const retval = await response.json();
if (retval.id) {
useSettingsStore.getState().updateSetting(retval);
}
return retval;
}
}

View file

@ -13,6 +13,7 @@ import {
VideoFile as VideoFileIcon,
LiveTv as LiveTvIcon,
PlaylistPlay as PlaylistPlayIcon,
Settings as SettingsIcon,
} from '@mui/icons-material';
const items = [
@ -25,6 +26,7 @@ const items = [
route: '/stream-profiles',
},
{ text: 'TV Guide', icon: <LiveTvIcon />, route: '/guide' },
{ text: 'Settings', icon: <SettingsIcon />, route: '/settings' },
];
const Sidebar = ({ open }) => {

View file

@ -401,6 +401,20 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
helperText={formik.touched.tvg_id && formik.errors.tvg_id}
variant="standard"
/>
<TextField
fullWidth
id="logo_url"
name="logo_url"
label="Logo URL (Optional)"
variant="standard"
sx={{ marginBottom: 2 }}
value={formik.values.logo_url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
helperText="If you have a direct image URL, set it here."
/>
<Box mt={2} mb={2}>
{/* File upload input */}
@ -472,4 +486,4 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
);
};
export default Channel;
export default Channel;

View file

@ -1,15 +1,10 @@
// Modal.js
import React, { useState, useEffect } from "react";
import React, { useState, useEffect } from 'react';
import {
Box,
Modal,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
@ -17,11 +12,11 @@ import {
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useEPGsStore from "../../store/epgs";
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useEPGsStore from '../../store/epgs';
const EPG = ({ epg = null, isOpen, onClose }) => {
const epgs = useEPGsStore((state) => state.epgs);
@ -36,15 +31,15 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: "",
source_type: "",
url: "",
api_key: "",
name: '',
source_type: '',
url: '',
api_key: '',
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required("Name is required"),
source_type: Yup.string().required("Source type is required"),
name: Yup.string().required('Name is required'),
source_type: Yup.string().required('Source type is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (epg?.id) {
@ -85,8 +80,8 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
EPG Source
@ -169,7 +164,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>

View file

@ -1,5 +1,5 @@
// Modal.js
import React, { useState, useEffect } from "react";
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
@ -17,15 +17,17 @@ import {
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useUserAgentsStore from "../../store/userAgents";
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import M3UProfiles from './M3UProfiles';
const M3U = ({ playlist = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const handleFileChange = (e) => {
const file = e.target.files[0];
@ -36,15 +38,15 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: "",
server_url: "",
name: '',
server_url: '',
max_streams: 0,
user_agent: "",
user_agent: '',
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required("Name is required"),
user_agent: Yup.string().required("User-Agent is required"),
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (playlist?.id) {
@ -89,8 +91,8 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
M3U Account
@ -131,7 +133,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
direction="row"
spacing={2}
sx={{
alignItems: "center",
alignItems: 'center',
pt: 2,
}}
>
@ -143,7 +145,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
name="uploaded_file"
accept="image/*"
onChange={(event) => handleFileChange(event)}
style={{ display: "none" }}
style={{ display: 'none' }}
/>
<label htmlFor="uploaded_file">
<Button variant="contained" component="span">
@ -198,7 +200,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
name="is_active"
checked={formik.values.is_active}
onChange={(e) =>
formik.setFieldValue("is_active", e.target.checked)
formik.setFieldValue('is_active', e.target.checked)
}
/>
}
@ -207,18 +209,33 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
{playlist && (
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
)}
</form>
</Dialog>
);

View file

@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react';
import {
TextField,
Typography,
Card,
CardContent,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Button,
CircularProgress,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [searchPattern, setSearchPattern] = useState('');
const [replacePattern, setReplacePattern] = useState('');
let regex;
try {
regex = new RegExp(searchPattern, 'g');
} catch (e) {
regex = null;
}
const highlightedUrl = regex
? m3u.server_url.replace(regex, (match) => `<mark>${match}</mark>`)
: m3u.server_url;
const resultUrl = regex
? m3u.server_url.replace(regex, replacePattern)
: m3u.server_url;
const onSearchPatternUpdate = (e) => {
formik.handleChange(e);
setSearchPattern(e.target.value);
};
const onReplacePatternUpdate = (e) => {
formik.handleChange(e);
setReplacePattern(e.target.value);
};
const formik = useFormik({
initialValues: {
name: '',
max_streams: 0,
search_pattern: '',
replace_pattern: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().required('Search pattern is required'),
replace_pattern: Yup.string().required('Replace pattern is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log('submiting');
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
...values,
});
} else {
await API.addM3UProfile(m3u.id, values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
setSearchPattern(profile.search_pattern);
setReplacePattern(profile.replace_pattern);
formik.setValues({
name: profile.name,
max_streams: profile.max_streams,
search_pattern: profile.search_pattern,
replace_pattern: profile.replace_pattern,
});
} else {
formik.resetForm();
}
}, [profile]);
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{ backgroundColor: 'primary.main', color: 'primary.contrastText' }}
>
M3U Profile
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.max_streams && Boolean(formik.errors.max_streams)
}
helperText={formik.touched.max_streams && formik.errors.max_streams}
variant="standard"
/>
<TextField
fullWidth
id="search_pattern"
name="search_pattern"
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
onBlur={formik.handleBlur}
error={
formik.touched.search_pattern &&
Boolean(formik.errors.search_pattern)
}
helperText={
formik.touched.search_pattern && formik.errors.search_pattern
}
variant="standard"
/>
<TextField
fullWidth
id="replace_pattern"
name="replace_pattern"
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
onBlur={formik.handleBlur}
error={
formik.touched.replace_pattern &&
Boolean(formik.errors.replace_pattern)
}
helperText={
formik.touched.replace_pattern && formik.errors.replace_pattern
}
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
<Card>
<CardContent>
<Typography variant="h6">Search</Typography>
<Typography
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
/>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6">Replace</Typography>
<Typography>{resultUrl}</Typography>
</CardContent>
</Card>
</Dialog>
);
};
export default RegexFormAndView;

View file

@ -0,0 +1,133 @@
import React, { useState, useMemo } from 'react';
import {
Typography,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Button,
Box,
Switch,
IconButton,
List,
ListItem,
ListItemText,
} from '@mui/material';
import API from '../../api';
import M3UProfile from './M3UProfile';
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
import usePlaylistsStore from '../../store/playlists';
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]);
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
const [profile, setProfile] = useState(null);
const editProfile = (profile = null) => {
if (profile) {
setProfile(profile);
}
setProfileEditorOpen(true);
};
const deleteProfile = async (id) => {
await API.deleteM3UProfile(playlist.id, id);
};
const toggleActive = async (values) => {
await API.updateM3UProfile(playlist.id, {
...values,
is_active: !values.is_active,
});
};
const closeEditor = () => {
setProfile(null);
setProfileEditorOpen(false);
};
if (!isOpen || !profiles) {
return <></>;
}
return (
<>
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Profiles
</DialogTitle>
<DialogContent>
<List>
{profiles
.filter((playlist) => playlist.is_default == false)
.map((item) => (
<ListItem
key={item.id}
sx={{
display: 'flex',
alignItems: 'center',
marginBottom: 2,
}}
>
<ListItemText
primary={item.name}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ marginRight: 2 }}>
Max Streams: {item.max_streams}
</Typography>
<Switch
checked={item.is_active}
onChange={() => toggleActive(item)}
color="primary"
inputProps={{ 'aria-label': 'active switch' }}
/>
<IconButton
onClick={() => editProfile(item)}
color="warning"
>
<EditIcon />
</IconButton>
<IconButton
onClick={() => deleteProfile(item.id)}
color="error"
>
<DeleteIcon />
</IconButton>
</Box>
}
/>
</ListItem>
))}
</List>
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="primary"
size="small"
onClick={editProfile}
>
New
</Button>
</DialogActions>
</Dialog>
<M3UProfile
m3u={playlist}
profile={profile}
isOpen={profileEditorOpen}
onClose={closeEditor}
/>
</>
);
};
export default M3UProfiles;

View file

@ -22,29 +22,31 @@ import {
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
LiveTv as LiveTvIcon,
ContentCopy,
} from '@mui/icons-material';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import { ContentCopy } from '@mui/icons-material';
import logo from '../../images/logo.png';
import useVideoStore from '../../store/useVideoStore'; // NEW import
const Example = () => {
const ChannelsTable = () => {
const [channel, setChannel] = useState(null);
const [channelModelOpen, setChannelModalOpen] = useState(false);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [anchorEl, setAnchorEl] = useState(null);
const [textToCopy, setTextToCopy] = useState('');
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const { channels, isLoading: channelsLoading } = useChannelsStore();
const { showVideo } = useVideoStore.getState(); // or useVideoStore()
// Configure columns
const columns = useMemo(
//column definitions...
() => [
{
header: '#',
@ -62,8 +64,8 @@ const Example = () => {
{
header: 'Logo',
accessorKey: 'logo_url',
size: 50,
cell: (info) => (
size: 55,
Cell: ({ cell }) => (
<Grid2
container
direction="row"
@ -72,7 +74,7 @@ const Example = () => {
alignItems: 'center',
}}
>
<img src={info.getValue() || logo} width="20" />
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
</Grid2>
),
meta: {
@ -83,18 +85,16 @@ const Example = () => {
[]
);
//optionally access the underlying virtualizer instance
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const closeSnackbar = () => {
setSnackbarOpen(false);
};
const closeSnackbar = () => setSnackbarOpen(false);
const editChannel = async (channel = null) => {
setChannel(channel);
const editChannel = async (ch = null) => {
setChannel(ch);
setChannelModalOpen(true);
};
@ -102,7 +102,11 @@ const Example = () => {
await API.deleteChannel(id);
};
// @TODO: the bulk delete endpoint is currently broken
function handleWatchStream(channelNumber) {
showVideo(`/output/stream/${channelNumber}/`);
}
// (Optional) bulk delete, but your endpoint is @TODO
const deleteChannels = async () => {
setIsLoading(true);
const selected = table
@ -110,18 +114,39 @@ const Example = () => {
.rows.filter((row) => row.getIsSelected());
await utils.Limiter(
4,
selected.map((chan) => () => {
return deleteChannel(chan.original.id);
})
selected.map((chan) => () => deleteChannel(chan.original.id))
);
// await API.deleteChannels(selected.map((sel) => sel.id));
setIsLoading(false);
};
// ─────────────────────────────────────────────────────────
// The "Assign Channels" button logic
// ─────────────────────────────────────────────────────────
const assignChannels = async () => {
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await API.assignChannelNumbers(selected.map((sel) => sel.id));
try {
// Get row order from the table
const rowOrder = table.getRowModel().rows.map((row) => row.original.id);
// Call our custom API endpoint
const result = await API.assignChannelNumbers(rowOrder);
// We might get { message: "Channels have been auto-assigned!" }
setSnackbarMessage(result.message || 'Channels assigned');
setSnackbarOpen(true);
// Refresh the channel list
await useChannelsStore.getState().fetchChannels();
} catch (err) {
console.error(err);
setSnackbarMessage('Failed to assign channels');
setSnackbarOpen(true);
}
};
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);
};
useEffect(() => {
@ -131,7 +156,7 @@ const Example = () => {
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
// Scroll to the top of the table when sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
@ -143,6 +168,7 @@ const Example = () => {
setAnchorEl(null);
setSnackbarMessage('');
};
const openPopover = Boolean(anchorEl);
const handleCopy = async () => {
try {
@ -151,33 +177,35 @@ const Example = () => {
} catch (err) {
setSnackbarMessage('Failed to copy');
}
setSnackbarOpen(true);
};
const open = Boolean(anchorEl);
const copyM3UUrl = async (event) => {
// Example copy URLs
const copyM3UUrl = (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy('m3u url');
};
const copyEPGUrl = async (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy('epg url');
};
const copyHDHRUrl = async (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy('hdhr url');
setTextToCopy(
`${window.location.protocol}//${window.location.host}/output/m3u`
);
};
const copyEPGUrl = (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy(
`${window.location.protocol}//${window.location.host}/output/epg`
);
};
const copyHDHRUrl = (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy(
`${window.location.protocol}//${window.location.host}/output/hdhr`
);
};
// Configure the MaterialReactTable
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: channels,
enablePagination: false,
// enableRowNumbers: true,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
@ -187,8 +215,8 @@ const Example = () => {
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
rowVirtualizerInstanceRef, // optional
rowVirtualizerOptions: { overscan: 5 },
initialState: {
density: 'compact',
},
@ -196,78 +224,77 @@ const Example = () => {
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
size="small"
color="warning"
onClick={() => {
editChannel(row.original);
}}
sx={{ p: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
size="small"
color="error"
onClick={() => deleteChannel(row.original.id)}
sx={{ p: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
<DeleteIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="info"
onClick={() => handleWatchStream(row.original.channel_number)}
sx={{ p: 0 }}
>
<LiveTvIcon fontSize="small" />
</IconButton>
</Box>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
height: 'calc(100vh - 75px)',
overflowY: 'auto',
},
},
muiSearchTextFieldProps: {
variant: 'standard',
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Channels</Typography>
<Tooltip title="Add New Channel">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
size="small"
color="success"
variant="contained"
onClick={() => editChannel()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Channels">
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
size="small"
color="error"
variant="contained"
onClick={deleteChannels}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Assign Channels">
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
size="small"
color="warning"
variant="contained"
onClick={assignChannels}
>
<SwapVertIcon fontSize="small" /> {/* Small icon size */}
<SwapVertIcon fontSize="small" />
</IconButton>
</Tooltip>
<ButtonGroup
sx={{
marginLeft: 1,
}}
>
<ButtonGroup sx={{ marginLeft: 1 }}>
<Button variant="contained" size="small" onClick={copyHDHRUrl}>
HDHR URL
</Button>
@ -285,14 +312,17 @@ const Example = () => {
return (
<Box>
<MaterialReactTable table={table} />
{/* Channel Form Modal */}
<ChannelForm
channel={channel}
isOpen={channelModelOpen}
onClose={() => setChannelModalOpen(false)}
isOpen={channelModalOpen}
onClose={closeChannelForm}
/>
{/* Popover for the "copy" URLs */}
<Popover
open={open}
open={openPopover}
anchorEl={anchorEl}
onClose={closePopover}
anchorOrigin={{
@ -300,7 +330,7 @@ const Example = () => {
horizontal: 'left',
}}
>
<div style={{ padding: '16px', display: 'flex', alignItems: 'center' }}>
<div style={{ padding: 16, display: 'flex', alignItems: 'center' }}>
<TextField
value={textToCopy}
variant="standard"
@ -312,9 +342,9 @@ const Example = () => {
<ContentCopy />
</IconButton>
</div>
{/* {copySuccess && <Typography variant="caption" sx={{ paddingLeft: 2 }}>{copySuccess}</Typography>} */}
</Popover>
{/* Snackbar for feedback */}
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
@ -326,4 +356,4 @@ const Example = () => {
);
};
export default Example;
export default ChannelsTable;

View file

@ -84,6 +84,11 @@ const EPGsTable = () => {
setSnackbarOpen(true);
};
const closeEPGForm = () => {
setEPG(null);
setEPGModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
@ -182,11 +187,7 @@ const EPGsTable = () => {
>
<MaterialReactTable table={table} />
<EPGForm
epg={epg}
isOpen={epgModalOpen}
onClose={() => setEPGModalOpen(false)}
/>
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}

View file

@ -1,18 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
} from '@mui/material';
@ -106,7 +102,9 @@ const Example = () => {
const [sorting, setSorting] = useState([]);
const editPlaylist = async (playlist = null) => {
setPlaylist(playlist);
if (playlist) {
setPlaylist(playlist);
}
setPlaylistModalOpen(true);
};
@ -118,6 +116,11 @@ const Example = () => {
await API.deletePlaylist(id);
};
const closeModal = () => {
setPlaylistModalOpen(false);
setPlaylist(null);
};
const deletePlaylists = async (ids) => {
const selected = table
.getRowModel()
@ -228,7 +231,7 @@ const Example = () => {
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}
onClose={() => setPlaylistModalOpen(false)}
onClose={closeModal}
/>
</Box>
);

View file

@ -116,6 +116,11 @@ const StreamProfiles = () => {
await API.deleteStreamProfile(ids);
};
const closeStreamProfileForm = () => {
setProfile(null);
setProfileModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
@ -210,7 +215,7 @@ const StreamProfiles = () => {
<StreamProfileForm
profile={profile}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
onClose={closeStreamProfileForm}
/>
</Box>
);

View file

@ -12,18 +12,21 @@ import {
Button,
} from '@mui/material';
import useStreamsStore from '../../store/streams';
import useChannelsStore from '../../store/channels'; // NEW: Import channels store
import API from '../../api';
// Make sure your api.js exports getAuthToken as a named export:
// e.g. export const getAuthToken = async () => { ... }
import { getAuthToken } from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
const Example = () => {
const StreamsTable = () => {
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
@ -31,16 +34,9 @@ const Example = () => {
const { playlists } = usePlaylistsStore();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Group',
accessorKey: 'group_name',
},
{ header: 'Name', accessorKey: 'name' },
{ header: 'Group', accessorKey: 'group_name' },
{
header: 'M3U',
size: 100,
@ -51,31 +47,31 @@ const Example = () => {
[playlists]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
channel_name: stream.name,
channel_number: 0,
channel_number: null,
stream_id: stream.id,
});
};
// @TODO: bulk create is broken, returning a 404
// Bulk creation: create channels from selected streams in one API call
const createChannelsFromStreams = async () => {
setIsLoading(true);
// Get all selected streams from the table
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await utils.Limiter(
4,
selected.map((stream) => () => {
return createChannelFromStream(stream.original);
})
await API.createChannelsFromStreams(
selected.map((sel) => ({
stream_id: sel.original.id,
channel_name: sel.original.name,
}))
);
setIsLoading(false);
};
@ -96,6 +92,11 @@ const Example = () => {
await API.deleteStreams(selected.map((stream) => stream.original.id));
};
const closeStreamForm = () => {
setStream(null);
setModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
@ -103,7 +104,6 @@ const Example = () => {
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
@ -113,7 +113,6 @@ const Example = () => {
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: streams,
enablePagination: false,
@ -126,14 +125,14 @@ const Example = () => {
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 },
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
size="small"
color="warning"
onClick={() => editStream(row.original)}
disabled={row.original.m3u_account}
sx={{ p: 0 }}
@ -141,16 +140,16 @@ const Example = () => {
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
size="small"
color="error"
onClick={() => deleteStream(row.original.id)}
sx={{ p: 0 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
size="small"
color="success"
onClick={() => createChannelFromStream(row.original)}
sx={{ p: 0 }}
>
@ -160,46 +159,38 @@ const Example = () => {
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
height: 'calc(100vh - 75px)',
overflowY: 'auto',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Streams</Typography>
<Tooltip title="Add New Stream">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
size="small"
color="success"
variant="contained"
onClick={() => editStream()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Streams">
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
size="small"
color="error"
variant="contained"
onClick={deleteStreams}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Button
variant="contained"
onClick={createChannelsFromStreams}
size="small"
// disabled={rowSelection.length === 0}
sx={{
marginLeft: 1,
}}
sx={{ marginLeft: 1 }}
>
Create Channels
</Button>
@ -208,24 +199,15 @@ const Example = () => {
});
return (
<Box
sx={
{
// paddingTop: 2,
// paddingLeft: 1,
// paddingRight: 2,
// paddingBottom: 2,
}
}
>
<Box>
<MaterialReactTable table={table} />
<StreamForm
stream={stream}
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onClose={closeStreamForm}
/>
</Box>
);
};
export default Example;
export default StreamsTable;

View file

@ -114,6 +114,11 @@ const UserAgentsTable = () => {
}
};
const closeUserAgentForm = () => {
setUserAgent(null);
setUserAgentModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
@ -210,7 +215,7 @@ const UserAgentsTable = () => {
<UserAgentForm
userAgent={userAgent}
isOpen={userAgentModalOpen}
onClose={() => setUserAgentModalOpen(false)}
onClose={closeUserAgentForm}
/>
</>
);

View file

@ -11,6 +11,8 @@ import {
DialogActions,
Button,
Slide,
CircularProgress,
Backdrop,
} from '@mui/material';
import dayjs from 'dayjs';
import API from '../api';
@ -19,10 +21,10 @@ import logo from '../images/logo.png';
import useVideoStore from '../store/useVideoStore'; // NEW import
/** Layout constants */
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
const PROGRAM_HEIGHT = 90; // Height of each channel row
const HOUR_WIDTH = 300; // The width for a 1-hour block
const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
const PROGRAM_HEIGHT = 90; // Height of each channel row
const HOUR_WIDTH = 300; // The width for a 1-hour block
const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
// Modal size constants
@ -41,6 +43,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const [guideChannels, setGuideChannels] = useState([]);
const [now, setNow] = useState(dayjs());
const [selectedProgram, setSelectedProgram] = useState(null);
const [loading, setLoading] = useState(true);
const guideRef = useRef(null);
@ -69,6 +72,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
setGuideChannels(filteredChannels);
setPrograms(fetched);
setLoading(false);
};
fetchPrograms();
@ -129,7 +133,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
if (guideRef.current) {
const nowOffset = dayjs().diff(start, 'minute');
const scrollPosition =
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
MINUTE_BLOCK_WIDTH;
guideRef.current.scrollLeft = Math.max(scrollPosition, 0);
}
}, [programs, start]);
@ -163,7 +168,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
// Build a playable stream URL for that channel
const url = window.location.origin + '/output/stream/' + matched.id;
const url =
window.location.origin + '/output/stream/' + matched.channel_number;
showVideo(url);
// Optionally close the modal
@ -173,7 +179,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
// On program click, open the details modal
function handleProgramClick(program, event) {
// Optionally scroll that element into view or do something else
event.currentTarget.scrollIntoView({ behavior: 'smooth', inline: 'center' });
event.currentTarget.scrollIntoView({
behavior: 'smooth',
inline: 'center',
});
setSelectedProgram(program);
}
@ -243,6 +252,25 @@ export default function TVChannelGuide({ startDate, endDate }) {
);
}
if (loading) {
return (
<Backdrop
sx={{
// color: '#fff',
zIndex: (theme) => theme.zIndex.drawer + 1,
position: 'fixed', // Ensure it covers the entire page
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
open={loading}
>
<CircularProgress color="inherit" />
</Backdrop>
);
}
return (
<Box
sx={{
@ -383,7 +411,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
width: '1px',
height: '10px',
backgroundColor: '#718096',
marginRight: i < 3 ? (HOUR_WIDTH / 4 - 1) + 'px' : 0,
marginRight: i < 3 ? HOUR_WIDTH / 4 - 1 + 'px' : 0,
}}
/>
))}
@ -473,14 +501,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
<DialogActions>
{/* Only show the Watch button if currently live */}
{now.isAfter(dayjs(selectedProgram.start_time)) &&
now.isBefore(dayjs(selectedProgram.end_time)) && (
<Button
onClick={() => handleWatchStream(selectedProgram)}
sx={{ color: '#38b2ac' }}
>
Watch Now
</Button>
)}
now.isBefore(dayjs(selectedProgram.end_time)) && (
<Button
onClick={() => handleWatchStream(selectedProgram)}
sx={{ color: '#38b2ac' }}
>
Watch Now
</Button>
)}
<Button onClick={handleCloseModal} sx={{ color: '#38b2ac' }}>
Close
</Button>

View file

@ -0,0 +1,149 @@
import React, { useEffect, useState } from 'react';
import {
Grid2,
Box,
Container,
Typography,
TextField,
Button,
FormControl,
Select,
MenuItem,
CircularProgress,
InputLabel,
} from '@mui/material';
import useSettingsStore from '../store/settings';
import useUserAgentsStore from '../store/userAgents';
import useStreamProfilesStore from '../store/streamProfiles';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../api';
const SettingsPage = () => {
const { settings } = useSettingsStore();
const { userAgents } = useUserAgentsStore();
const { profiles: streamProfiles } = useStreamProfilesStore();
const formik = useFormik({
initialValues: {
'default-user-agent': '',
'default-stream-profile': '',
},
validationSchema: Yup.object({
'default-user-agent': Yup.string().required('User-Agent is required'),
'default-stream-profile': Yup.string().required(
'Stream Profile is required'
),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
const changedSettings = {};
for (const setting in values) {
if (values[setting] != settings[setting].value) {
changedSettings[setting] = values[setting];
}
}
console.log(changedSettings);
for (const updated in changedSettings) {
await API.updateSetting({
...settings[updated],
value: values[updated],
});
}
},
});
useEffect(() => {
formik.setValues(
Object.values(settings).reduce((acc, setting) => {
acc[setting.key] = parseInt(setting.value) || setting.value;
return acc;
}, {})
);
}, [settings, streamProfiles, userAgents]);
return (
<Container maxWidth="md">
<Box mt={4}>
<Typography variant="h4" gutterBottom>
Settings
</Typography>
<form onSubmit={formik.handleSubmit}>
<Grid2 container spacing={3}>
<FormControl variant="standard" fullWidth>
<InputLabel id="user-agent-label">Default User-Agent</InputLabel>
<Select
labelId="user-agent-label"
id={settings['default-user-agent'].id}
name={settings['default-user-agent'].key}
label={settings['default-user-agent'].name}
value={formik.values['default-user-agent']}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched['default-user-agent'] &&
Boolean(formik.errors['default-user-agent'])
}
helperText={
formik.touched['default-user-agent'] &&
formik.errors['default-user-agent']
}
variant="standard"
>
{userAgents.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Default Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id={settings['default-stream-profile'].id}
name={settings['default-stream-profile'].key}
label={settings['default-stream-profile'].name}
value={formik.values['default-stream-profile']}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched['default-stream-profile'] &&
Boolean(formik.errors['default-stream-profile'])
}
helperText={
formik.touched['default-stream-profile'] &&
formik.errors['default-stream-profile']
}
variant="standard"
>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid2>
<Box mt={4} display="flex" justifyContent="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</Box>
</form>
</Box>
</Container>
);
};
export default SettingsPage;

View file

@ -6,6 +6,7 @@ import useUserAgentsStore from './userAgents';
import usePlaylistsStore from './playlists';
import useEPGsStore from './epgs';
import useStreamProfilesStore from './streamProfiles';
import useSettingsStore from './settings';
const decodeToken = (token) => {
if (!token) return null;
@ -37,6 +38,7 @@ const useAuthStore = create((set, get) => ({
usePlaylistsStore.getState().fetchPlaylists(),
useEPGsStore.getState().fetchEPGs(),
useStreamProfilesStore.getState().fetchProfiles(),
useSettingsStore.getState().fetchSettings(),
]);
},

View file

@ -1,5 +1,5 @@
import { create } from "zustand";
import api from "../api";
import { create } from 'zustand';
import api from '../api';
const useChannelsStore = create((set) => ({
channels: [],
@ -13,8 +13,8 @@ const useChannelsStore = create((set) => ({
const channels = await api.getChannels();
set({ channels: channels, isLoading: false });
} catch (error) {
console.error("Failed to fetch channels:", error);
set({ error: "Failed to load channels.", isLoading: false });
console.error('Failed to fetch channels:', error);
set({ error: 'Failed to load channels.', isLoading: false });
}
},
@ -24,8 +24,8 @@ const useChannelsStore = create((set) => ({
const channelGroups = await api.getChannelGroups();
set({ channelGroups: channelGroups, isLoading: false });
} catch (error) {
console.error("Failed to fetch channel groups:", error);
set({ error: "Failed to load channel groups.", isLoading: false });
console.error('Failed to fetch channel groups:', error);
set({ error: 'Failed to load channel groups.', isLoading: false });
}
},
@ -34,17 +34,22 @@ const useChannelsStore = create((set) => ({
channels: [...state.channels, newChannel],
})),
addChannels: (newChannels) =>
set((state) => ({
channels: state.channels.concat(newChannels),
})),
updateChannel: (userAgent) =>
set((state) => ({
channels: state.channels.map((chan) =>
chan.id === userAgent.id ? userAgent : chan,
chan.id === userAgent.id ? userAgent : chan
),
})),
removeChannels: (channelIds) =>
set((state) => ({
channels: state.channels.filter(
(channel) => !channelIds.includes(channel.id),
(channel) => !channelIds.includes(channel.id)
),
})),
@ -56,7 +61,7 @@ const useChannelsStore = create((set) => ({
updateChannelGroup: (channelGroup) =>
set((state) => ({
channelGroups: state.channelGroups.map((group) =>
group.id === channelGroup.id ? channelGroup : group,
group.id === channelGroup.id ? channelGroup : group
),
})),
}));

View file

@ -1,8 +1,9 @@
import { create } from "zustand";
import api from "../api";
import { create } from 'zustand';
import api from '../api';
const usePlaylistsStore = create((set) => ({
playlists: [],
profiles: {},
isLoading: false,
error: null,
@ -10,30 +11,54 @@ const usePlaylistsStore = create((set) => ({
set({ isLoading: true, error: null });
try {
const playlists = await api.getPlaylists();
set({ playlists: playlists, isLoading: false });
set({
playlists: playlists,
isLoading: false,
profiles: playlists.reduce((acc, playlist) => {
acc[playlist.id] = playlist.profiles;
return acc;
}, {}),
});
} catch (error) {
console.error("Failed to fetch playlists:", error);
set({ error: "Failed to load playlists.", isLoading: false });
console.error('Failed to fetch playlists:', error);
set({ error: 'Failed to load playlists.', isLoading: false });
}
},
addPlaylist: (newPlaylist) =>
set((state) => ({
playlists: [...state.playlists, newPlaylist],
profiles: {
...state.profiles,
[newPlaylist.id]: newPlaylist.profiles,
},
})),
updatePlaylist: (playlist) =>
set((state) => ({
playlists: state.playlists.map((pl) =>
pl.id === playlist.id ? playlist : pl,
pl.id === playlist.id ? playlist : pl
),
profiles: {
...state.profiles,
[playlist.id]: playlist.profiles,
},
})),
updateProfiles: (playlistId, profiles) =>
set((state) => ({
profiles: {
...state.profiles,
[playlistId]: profiles,
},
})),
removePlaylists: (playlistIds) =>
set((state) => ({
playlists: state.playlists.filter(
(playlist) => !playlistIds.includes(playlist.id),
(playlist) => !playlistIds.includes(playlist.id)
),
// @TODO: remove playlist profiles here
})),
}));

View file

@ -0,0 +1,32 @@
import { create } from 'zustand';
import api from '../api';
const useSettingsStore = create((set) => ({
settings: {},
isLoading: false,
error: null,
fetchSettings: async () => {
set({ isLoading: true, error: null });
try {
const settings = await api.getSettings();
set({
settings: settings.reduce((acc, setting) => {
acc[setting.key] = setting;
return acc;
}, {}),
isLoading: false,
});
} catch (error) {
console.error('Failed to fetch settings:', error);
set({ error: 'Failed to load settings.', isLoading: false });
}
},
updateSetting: (setting) =>
set((state) => ({
settings: { ...state.settings, [setting.key]: setting },
})),
}));
export default useSettingsStore;

View file

@ -8,15 +8,17 @@ const useVideoStore = create((set) => ({
isVisible: false,
streamUrl: null,
showVideo: (url) => set({
isVisible: true,
streamUrl: url,
}),
showVideo: (url) =>
set({
isVisible: true,
streamUrl: url,
}),
hideVideo: () => set({
isVisible: false,
streamUrl: null,
}),
hideVideo: () =>
set({
isVisible: false,
streamUrl: null,
}),
}));
export default useVideoStore;

View file

@ -2,7 +2,7 @@ Django==5.1.6
gunicorn==23.0.0
psycopg2-binary==2.9.10
redis==4.5.5
celery==5.2.7
celery
djangorestframework==3.15.2
requests==2.32.3
psutil==7.0.0

View file

@ -1,279 +0,0 @@
select.admin-autocomplete {
width: 20em;
}
.select2-container--admin-autocomplete.select2-container {
min-height: 30px;
}
.select2-container--admin-autocomplete .select2-selection--single,
.select2-container--admin-autocomplete .select2-selection--multiple {
min-height: 30px;
padding: 0;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection,
.select2-container--admin-autocomplete.select2-container--open .select2-selection {
border-color: var(--body-quiet-color);
min-height: 30px;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single,
.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single {
padding: 0;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple,
.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple {
padding: 0;
}
.select2-container--admin-autocomplete .select2-selection--single {
background-color: var(--body-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered {
color: var(--body-fg);
line-height: 30px;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder {
color: var(--body-quiet-color);
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow {
height: 26px;
position: absolute;
top: 1px;
right: 1px;
width: 20px;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b {
border-color: #888 transparent transparent transparent;
border-style: solid;
border-width: 5px 4px 0 4px;
height: 0;
left: 50%;
margin-left: -4px;
margin-top: -2px;
position: absolute;
top: 50%;
width: 0;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear {
float: left;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow {
left: 1px;
right: auto;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single {
background-color: var(--darkened-bg);
cursor: default;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear {
display: none;
}
.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #888 transparent;
border-width: 0 4px 5px 4px;
}
.select2-container--admin-autocomplete .select2-selection--multiple {
background-color: var(--body-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: text;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered {
box-sizing: border-box;
list-style: none;
margin: 0;
padding: 0 10px 5px 5px;
width: 100%;
display: flex;
flex-wrap: wrap;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li {
list-style: none;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder {
color: var(--body-quiet-color);
margin-top: 5px;
float: left;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
margin: 5px;
position: absolute;
right: 0;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice {
background-color: var(--darkened-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: default;
float: left;
margin-right: 5px;
margin-top: 5px;
padding: 0 5px;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove {
color: var(--body-quiet-color);
cursor: pointer;
display: inline-block;
font-weight: bold;
margin-right: 2px;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover {
color: var(--body-fg);
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline {
float: right;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
margin-left: 5px;
margin-right: auto;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
margin-left: 2px;
margin-right: auto;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple {
border: solid var(--body-quiet-color) 1px;
outline: 0;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple {
background-color: var(--darkened-bg);
cursor: default;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove {
display: none;
}
.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.select2-container--admin-autocomplete .select2-search--dropdown {
background: var(--darkened-bg);
}
.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field {
background: var(--body-bg);
color: var(--body-fg);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.select2-container--admin-autocomplete .select2-search--inline .select2-search__field {
background: transparent;
color: var(--body-fg);
border: none;
outline: 0;
box-shadow: none;
-webkit-appearance: textfield;
}
.select2-container--admin-autocomplete .select2-results > .select2-results__options {
max-height: 200px;
overflow-y: auto;
color: var(--body-fg);
background: var(--body-bg);
}
.select2-container--admin-autocomplete .select2-results__option[role=group] {
padding: 0;
}
.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] {
color: var(--body-quiet-color);
}
.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] {
background-color: var(--selected-bg);
color: var(--body-fg);
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option {
padding-left: 1em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group {
padding-left: 0;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option {
margin-left: -1em;
padding-left: 2em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -2em;
padding-left: 3em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -3em;
padding-left: 4em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -4em;
padding-left: 5em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -5em;
padding-left: 6em;
}
.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] {
background-color: var(--primary);
color: var(--primary-fg);
}
.select2-container--admin-autocomplete .select2-results__group {
cursor: default;
display: block;
padding: 6px;
}
.errors .select2-selection {
border: 1px solid var(--error-fg);
}

File diff suppressed because it is too large Load diff

View file

@ -1,343 +0,0 @@
/* CHANGELISTS */
#changelist {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
#changelist .changelist-form-container {
flex: 1 1 auto;
min-width: 0;
}
#changelist table {
width: 100%;
}
.change-list .hiddenfields { display:none; }
.change-list .filtered table {
border-right: none;
}
.change-list .filtered {
min-height: 400px;
}
.change-list .filtered .results, .change-list .filtered .paginator,
.filtered #toolbar, .filtered div.xfull {
width: auto;
}
.change-list .filtered table tbody th {
padding-right: 1em;
}
#changelist-form .results {
overflow-x: auto;
width: 100%;
}
#changelist .toplinks {
border-bottom: 1px solid var(--hairline-color);
}
#changelist .paginator {
color: var(--body-quiet-color);
border-bottom: 1px solid var(--hairline-color);
background: var(--body-bg);
overflow: hidden;
}
/* CHANGELIST TABLES */
#changelist table thead th {
padding: 0;
white-space: nowrap;
vertical-align: middle;
}
#changelist table thead th.action-checkbox-column {
width: 1.5em;
text-align: center;
}
#changelist table tbody td.action-checkbox {
text-align: center;
}
#changelist table tfoot {
color: var(--body-quiet-color);
}
/* TOOLBAR */
#toolbar {
padding: 8px 10px;
margin-bottom: 15px;
border-top: 1px solid var(--hairline-color);
border-bottom: 1px solid var(--hairline-color);
background: var(--darkened-bg);
color: var(--body-quiet-color);
}
#toolbar form input {
border-radius: 4px;
font-size: 0.875rem;
padding: 5px;
color: var(--body-fg);
}
#toolbar #searchbar {
height: 1.1875rem;
border: 1px solid var(--border-color);
padding: 2px 5px;
margin: 0;
vertical-align: top;
font-size: 0.8125rem;
max-width: 100%;
}
#toolbar #searchbar:focus {
border-color: var(--body-quiet-color);
}
#toolbar form input[type="submit"] {
border: 1px solid var(--border-color);
font-size: 0.8125rem;
padding: 4px 8px;
margin: 0;
vertical-align: middle;
background: var(--body-bg);
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
cursor: pointer;
color: var(--body-fg);
}
#toolbar form input[type="submit"]:focus,
#toolbar form input[type="submit"]:hover {
border-color: var(--body-quiet-color);
}
#changelist-search img {
vertical-align: middle;
margin-right: 4px;
}
#changelist-search .help {
word-break: break-word;
}
/* FILTER COLUMN */
#changelist-filter {
flex: 0 0 240px;
order: 1;
background: var(--darkened-bg);
border-left: none;
margin: 0 0 0 30px;
}
@media (forced-colors: active) {
#changelist-filter {
border: 1px solid;
}
}
#changelist-filter h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 5px 15px;
margin-bottom: 12px;
border-bottom: none;
}
#changelist-filter h3,
#changelist-filter details summary {
font-weight: 400;
padding: 0 15px;
margin-bottom: 10px;
}
#changelist-filter details summary > * {
display: inline;
}
#changelist-filter details > summary {
list-style-type: none;
}
#changelist-filter details > summary::-webkit-details-marker {
display: none;
}
#changelist-filter details > summary::before {
content: '→';
font-weight: bold;
color: var(--link-hover-color);
}
#changelist-filter details[open] > summary::before {
content: '↓';
}
#changelist-filter ul {
margin: 5px 0;
padding: 0 15px 15px;
border-bottom: 1px solid var(--hairline-color);
}
#changelist-filter ul:last-child {
border-bottom: none;
}
#changelist-filter li {
list-style-type: none;
margin-left: 0;
padding-left: 0;
}
#changelist-filter a {
display: block;
color: var(--body-quiet-color);
word-break: break-word;
}
#changelist-filter li.selected {
border-left: 5px solid var(--hairline-color);
padding-left: 10px;
margin-left: -15px;
}
#changelist-filter li.selected a {
color: var(--link-selected-fg);
}
#changelist-filter a:focus, #changelist-filter a:hover,
#changelist-filter li.selected a:focus,
#changelist-filter li.selected a:hover {
color: var(--link-hover-color);
}
#changelist-filter #changelist-filter-extra-actions {
font-size: 0.8125rem;
margin-bottom: 10px;
border-bottom: 1px solid var(--hairline-color);
}
/* DATE DRILLDOWN */
.change-list .toplinks {
display: flex;
padding-bottom: 5px;
flex-wrap: wrap;
gap: 3px 17px;
font-weight: bold;
}
.change-list .toplinks a {
font-size: 0.8125rem;
}
.change-list .toplinks .date-back {
color: var(--body-quiet-color);
}
.change-list .toplinks .date-back:focus,
.change-list .toplinks .date-back:hover {
color: var(--link-hover-color);
}
/* ACTIONS */
.filtered .actions {
border-right: none;
}
#changelist table input {
margin: 0;
vertical-align: baseline;
}
/* Once the :has() pseudo-class is supported by all browsers, the tr.selected
selector and the JS adding the class can be removed. */
#changelist tbody tr.selected {
background-color: var(--selected-row);
}
#changelist tbody tr:has(.action-select:checked) {
background-color: var(--selected-row);
}
@media (forced-colors: active) {
#changelist tbody tr.selected {
background-color: SelectedItem;
}
#changelist tbody tr:has(.action-select:checked) {
background-color: SelectedItem;
}
}
#changelist .actions {
padding: 10px;
background: var(--body-bg);
border-top: none;
border-bottom: none;
line-height: 1.5rem;
color: var(--body-quiet-color);
width: 100%;
}
#changelist .actions span.all,
#changelist .actions span.action-counter,
#changelist .actions span.clear,
#changelist .actions span.question {
font-size: 0.8125rem;
margin: 0 0.5em;
}
#changelist .actions:last-child {
border-bottom: none;
}
#changelist .actions select {
vertical-align: top;
height: 1.5rem;
color: var(--body-fg);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.875rem;
padding: 0 0 0 4px;
margin: 0;
margin-left: 10px;
}
#changelist .actions select:focus {
border-color: var(--body-quiet-color);
}
#changelist .actions label {
display: inline-block;
vertical-align: middle;
font-size: 0.8125rem;
}
#changelist .actions .button {
font-size: 0.8125rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--body-bg);
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
cursor: pointer;
height: 1.5rem;
line-height: 1;
padding: 4px 8px;
margin: 0;
color: var(--body-fg);
}
#changelist .actions .button:focus, #changelist .actions .button:hover {
border-color: var(--body-quiet-color);
}

View file

@ -1,130 +0,0 @@
@media (prefers-color-scheme: dark) {
:root {
--primary: #264b5d;
--primary-fg: #f7f7f7;
--body-fg: #eeeeee;
--body-bg: #121212;
--body-quiet-color: #d0d0d0;
--body-medium-color: #e0e0e0;
--body-loud-color: #ffffff;
--breadcrumbs-link-fg: #e0e0e0;
--breadcrumbs-bg: var(--primary);
--link-fg: #81d4fa;
--link-hover-color: #4ac1f7;
--link-selected-fg: #6f94c6;
--hairline-color: #272727;
--border-color: #353535;
--error-fg: #e35f5f;
--message-success-bg: #006b1b;
--message-warning-bg: #583305;
--message-error-bg: #570808;
--darkened-bg: #212121;
--selected-bg: #1b1b1b;
--selected-row: #00363a;
--close-button-bg: #333333;
--close-button-hover-bg: #666666;
color-scheme: dark;
}
}
html[data-theme="dark"] {
--primary: #264b5d;
--primary-fg: #f7f7f7;
--body-fg: #eeeeee;
--body-bg: #121212;
--body-quiet-color: #d0d0d0;
--body-medium-color: #e0e0e0;
--body-loud-color: #ffffff;
--breadcrumbs-link-fg: #e0e0e0;
--breadcrumbs-bg: var(--primary);
--link-fg: #81d4fa;
--link-hover-color: #4ac1f7;
--link-selected-fg: #6f94c6;
--hairline-color: #272727;
--border-color: #353535;
--error-fg: #e35f5f;
--message-success-bg: #006b1b;
--message-warning-bg: #583305;
--message-error-bg: #570808;
--darkened-bg: #212121;
--selected-bg: #1b1b1b;
--selected-row: #00363a;
--close-button-bg: #333333;
--close-button-hover-bg: #666666;
color-scheme: dark;
}
/* THEME SWITCH */
.theme-toggle {
cursor: pointer;
border: none;
padding: 0;
background: transparent;
vertical-align: middle;
margin-inline-start: 5px;
margin-top: -1px;
}
.theme-toggle svg {
vertical-align: middle;
height: 1rem;
width: 1rem;
display: none;
}
/*
Fully hide screen reader text so we only show the one matching the current
theme.
*/
.theme-toggle .visually-hidden {
display: none;
}
html[data-theme="auto"] .theme-toggle .theme-label-when-auto {
display: block;
}
html[data-theme="dark"] .theme-toggle .theme-label-when-dark {
display: block;
}
html[data-theme="light"] .theme-toggle .theme-label-when-light {
display: block;
}
/* ICONS */
.theme-toggle svg.theme-icon-when-auto,
.theme-toggle svg.theme-icon-when-dark,
.theme-toggle svg.theme-icon-when-light {
fill: var(--header-link-color);
color: var(--header-bg);
}
html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto {
display: block;
}
html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark {
display: block;
}
html[data-theme="light"] .theme-toggle svg.theme-icon-when-light {
display: block;
}

View file

@ -1,29 +0,0 @@
/* DASHBOARD */
.dashboard td, .dashboard th {
word-break: break-word;
}
.dashboard .module table th {
width: 100%;
}
.dashboard .module table td {
white-space: nowrap;
}
.dashboard .module table td a {
display: block;
padding-right: .6em;
}
/* RECENT ACTIONS MODULE */
.module ul.actionlist {
margin-left: 0;
}
ul.actionlist li {
list-style-type: none;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -1,512 +0,0 @@
@import url('widgets.css');
/* FORM ROWS */
.form-row {
overflow: hidden;
padding: 10px;
font-size: 0.8125rem;
border-bottom: 1px solid var(--hairline-color);
}
.form-row img, .form-row input {
vertical-align: middle;
}
.form-row label input[type="checkbox"] {
margin-top: 0;
vertical-align: 0;
}
form .form-row p {
padding-left: 0;
}
.flex-container {
display: flex;
}
.form-multiline {
flex-wrap: wrap;
}
.form-multiline > div {
padding-bottom: 10px;
}
/* FORM LABELS */
label {
font-weight: normal;
color: var(--body-quiet-color);
font-size: 0.8125rem;
}
.required label, label.required {
font-weight: bold;
}
/* RADIO BUTTONS */
form div.radiolist div {
padding-right: 7px;
}
form div.radiolist.inline div {
display: inline-block;
}
form div.radiolist label {
width: auto;
}
form div.radiolist input[type="radio"] {
margin: -2px 4px 0 0;
padding: 0;
}
form ul.inline {
margin-left: 0;
padding: 0;
}
form ul.inline li {
float: left;
padding-right: 7px;
}
/* FIELDSETS */
fieldset .fieldset-heading,
fieldset .inline-heading,
:not(.inline-related) .collapse summary {
border: 1px solid var(--header-bg);
margin: 0;
padding: 8px;
font-weight: 400;
font-size: 0.8125rem;
background: var(--header-bg);
color: var(--header-link-color);
}
/* ALIGNED FIELDSETS */
.aligned label {
display: block;
padding: 4px 10px 0 0;
min-width: 160px;
width: 160px;
word-wrap: break-word;
}
.aligned label:not(.vCheckboxLabel):after {
content: '';
display: inline-block;
vertical-align: middle;
}
.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly {
padding: 6px 0;
margin-top: 0;
margin-bottom: 0;
margin-left: 0;
overflow-wrap: break-word;
}
.aligned ul label {
display: inline;
float: none;
width: auto;
}
.aligned .form-row input {
margin-bottom: 0;
}
.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField {
width: 350px;
}
form .aligned ul {
margin-left: 160px;
padding-left: 10px;
}
form .aligned div.radiolist {
display: inline-block;
margin: 0;
padding: 0;
}
form .aligned p.help,
form .aligned div.help {
margin-top: 0;
margin-left: 160px;
padding-left: 10px;
}
form .aligned p.date div.help.timezonewarning,
form .aligned p.datetime div.help.timezonewarning,
form .aligned p.time div.help.timezonewarning {
margin-left: 0;
padding-left: 0;
font-weight: normal;
}
form .aligned p.help:last-child,
form .aligned div.help:last-child {
margin-bottom: 0;
padding-bottom: 0;
}
form .aligned input + p.help,
form .aligned textarea + p.help,
form .aligned select + p.help,
form .aligned input + div.help,
form .aligned textarea + div.help,
form .aligned select + div.help {
margin-left: 160px;
padding-left: 10px;
}
form .aligned select option:checked {
background-color: var(--selected-row);
}
form .aligned ul li {
list-style: none;
}
form .aligned table p {
margin-left: 0;
padding-left: 0;
}
.aligned .vCheckboxLabel {
padding: 1px 0 0 5px;
}
.aligned .vCheckboxLabel + p.help,
.aligned .vCheckboxLabel + div.help {
margin-top: -4px;
}
.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField {
width: 610px;
}
fieldset .fieldBox {
margin-right: 20px;
}
/* WIDE FIELDSETS */
.wide label {
width: 200px;
}
form .wide p.help,
form .wide ul.errorlist,
form .wide div.help {
padding-left: 50px;
}
form div.help ul {
padding-left: 0;
margin-left: 0;
}
.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField {
width: 450px;
}
/* COLLAPSIBLE FIELDSETS */
.collapse summary .fieldset-heading,
.collapse summary .inline-heading {
background: transparent;
border: none;
color: currentColor;
display: inline;
margin: 0;
padding: 0;
}
/* MONOSPACE TEXTAREAS */
fieldset.monospace textarea {
font-family: var(--font-family-monospace);
}
/* SUBMIT ROW */
.submit-row {
padding: 12px 14px 12px;
margin: 0 0 20px;
background: var(--darkened-bg);
border: 1px solid var(--hairline-color);
border-radius: 4px;
overflow: hidden;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
body.popup .submit-row {
overflow: auto;
}
.submit-row input {
height: 2.1875rem;
line-height: 0.9375rem;
}
.submit-row input, .submit-row a {
margin: 0;
}
.submit-row input.default {
text-transform: uppercase;
}
.submit-row a.deletelink {
margin-left: auto;
}
.submit-row a.deletelink {
display: block;
background: var(--delete-button-bg);
border-radius: 4px;
padding: 0.625rem 0.9375rem;
height: 0.9375rem;
line-height: 0.9375rem;
color: var(--button-fg);
}
.submit-row a.closelink {
display: inline-block;
background: var(--close-button-bg);
border-radius: 4px;
padding: 10px 15px;
height: 0.9375rem;
line-height: 0.9375rem;
color: var(--button-fg);
}
.submit-row a.deletelink:focus,
.submit-row a.deletelink:hover,
.submit-row a.deletelink:active {
background: var(--delete-button-hover-bg);
text-decoration: none;
}
.submit-row a.closelink:focus,
.submit-row a.closelink:hover,
.submit-row a.closelink:active {
background: var(--close-button-hover-bg);
text-decoration: none;
}
/* CUSTOM FORM FIELDS */
.vSelectMultipleField {
vertical-align: top;
}
.vCheckboxField {
border: none;
}
.vDateField, .vTimeField {
margin-right: 2px;
margin-bottom: 4px;
}
.vDateField {
min-width: 6.85em;
}
.vTimeField {
min-width: 4.7em;
}
.vURLField {
width: 30em;
}
.vLargeTextField, .vXMLLargeTextField {
width: 48em;
}
.flatpages-flatpage #id_content {
height: 40.2em;
}
.module table .vPositiveSmallIntegerField {
width: 2.2em;
}
.vIntegerField {
width: 5em;
}
.vBigIntegerField {
width: 10em;
}
.vForeignKeyRawIdAdminField {
width: 5em;
}
.vTextField, .vUUIDField {
width: 20em;
}
/* INLINES */
.inline-group {
padding: 0;
margin: 0 0 30px;
}
.inline-group thead th {
padding: 8px 10px;
}
.inline-group .aligned label {
width: 160px;
}
.inline-related {
position: relative;
}
.inline-related h4,
.inline-related:not(.tabular) .collapse summary {
margin: 0;
color: var(--body-medium-color);
padding: 5px;
font-size: 0.8125rem;
background: var(--darkened-bg);
border: 1px solid var(--hairline-color);
border-left-color: var(--darkened-bg);
border-right-color: var(--darkened-bg);
}
.inline-related h3 span.delete {
float: right;
}
.inline-related h3 span.delete label {
margin-left: 2px;
font-size: 0.6875rem;
}
.inline-related fieldset {
margin: 0;
background: var(--body-bg);
border: none;
width: 100%;
}
.inline-group .tabular fieldset.module {
border: none;
}
.inline-related.tabular fieldset.module table {
width: 100%;
overflow-x: scroll;
}
.last-related fieldset {
border: none;
}
.inline-group .tabular tr.has_original td {
padding-top: 2em;
}
.inline-group .tabular tr td.original {
padding: 2px 0 0 0;
width: 0;
_position: relative;
}
.inline-group .tabular th.original {
width: 0px;
padding: 0;
}
.inline-group .tabular td.original p {
position: absolute;
left: 0;
height: 1.1em;
padding: 2px 9px;
overflow: hidden;
font-size: 0.5625rem;
font-weight: bold;
color: var(--body-quiet-color);
_width: 700px;
}
.inline-group ul.tools {
padding: 0;
margin: 0;
list-style: none;
}
.inline-group ul.tools li {
display: inline;
padding: 0 5px;
}
.inline-group div.add-row,
.inline-group .tabular tr.add-row td {
color: var(--body-quiet-color);
background: var(--darkened-bg);
padding: 8px 10px;
border-bottom: 1px solid var(--hairline-color);
}
.inline-group .tabular tr.add-row td {
padding: 8px 10px;
border-bottom: 1px solid var(--hairline-color);
}
.inline-group ul.tools a.add,
.inline-group div.add-row a,
.inline-group .tabular tr.add-row td a {
background: url(../img/icon-addlink.svg) 0 1px no-repeat;
padding-left: 16px;
font-size: 0.75rem;
}
.empty-form {
display: none;
}
/* RELATED FIELD ADD ONE / LOOKUP */
.related-lookup {
margin-left: 5px;
display: inline-block;
vertical-align: middle;
background-repeat: no-repeat;
background-size: 14px;
}
.related-lookup {
width: 1rem;
height: 1rem;
background-image: url(../img/search.svg);
}
form .related-widget-wrapper ul {
display: inline-block;
margin-left: 0;
padding-left: 0;
}
.clearable-file-input input {
margin-top: 0;
}

View file

@ -1,61 +0,0 @@
/* LOGIN FORM */
.login {
background: var(--darkened-bg);
height: auto;
}
.login #header {
height: auto;
padding: 15px 16px;
justify-content: center;
}
.login #header h1 {
font-size: 1.125rem;
margin: 0;
}
.login #header h1 a {
color: var(--header-link-color);
}
.login #content {
padding: 20px;
}
.login #container {
background: var(--body-bg);
border: 1px solid var(--hairline-color);
border-radius: 4px;
overflow: hidden;
width: 28em;
min-width: 300px;
margin: 100px auto;
height: auto;
}
.login .form-row {
padding: 4px 0;
}
.login .form-row label {
display: block;
line-height: 2em;
}
.login .form-row #id_username, .login .form-row #id_password {
padding: 8px;
width: 100%;
box-sizing: border-box;
}
.login .submit-row {
padding: 1em 0 0 0;
margin: 0;
text-align: center;
}
.login .password-reset-link {
text-align: center;
}

View file

@ -1,150 +0,0 @@
.sticky {
position: sticky;
top: 0;
max-height: 100vh;
}
.toggle-nav-sidebar {
z-index: 20;
left: 0;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 23px;
width: 23px;
border: 0;
border-right: 1px solid var(--hairline-color);
background-color: var(--body-bg);
cursor: pointer;
font-size: 1.25rem;
color: var(--link-fg);
padding: 0;
}
[dir="rtl"] .toggle-nav-sidebar {
border-left: 1px solid var(--hairline-color);
border-right: 0;
}
.toggle-nav-sidebar:hover,
.toggle-nav-sidebar:focus {
background-color: var(--darkened-bg);
}
#nav-sidebar {
z-index: 15;
flex: 0 0 275px;
left: -276px;
margin-left: -276px;
border-top: 1px solid transparent;
border-right: 1px solid var(--hairline-color);
background-color: var(--body-bg);
overflow: auto;
}
[dir="rtl"] #nav-sidebar {
border-left: 1px solid var(--hairline-color);
border-right: 0;
left: 0;
margin-left: 0;
right: -276px;
margin-right: -276px;
}
.toggle-nav-sidebar::before {
content: '\00BB';
}
.main.shifted .toggle-nav-sidebar::before {
content: '\00AB';
}
.main > #nav-sidebar {
visibility: hidden;
}
.main.shifted > #nav-sidebar {
margin-left: 0;
visibility: visible;
}
[dir="rtl"] .main.shifted > #nav-sidebar {
margin-right: 0;
}
#nav-sidebar .module th {
width: 100%;
overflow-wrap: anywhere;
}
#nav-sidebar .module th,
#nav-sidebar .module caption {
padding-left: 16px;
}
#nav-sidebar .module td {
white-space: nowrap;
}
[dir="rtl"] #nav-sidebar .module th,
[dir="rtl"] #nav-sidebar .module caption {
padding-left: 8px;
padding-right: 16px;
}
#nav-sidebar .current-app .section:link,
#nav-sidebar .current-app .section:visited {
color: var(--header-color);
font-weight: bold;
}
#nav-sidebar .current-model {
background: var(--selected-row);
}
@media (forced-colors: active) {
#nav-sidebar .current-model {
background-color: SelectedItem;
}
}
.main > #nav-sidebar + .content {
max-width: calc(100% - 23px);
}
.main.shifted > #nav-sidebar + .content {
max-width: calc(100% - 299px);
}
@media (max-width: 767px) {
#nav-sidebar, #toggle-nav-sidebar {
display: none;
}
.main > #nav-sidebar + .content,
.main.shifted > #nav-sidebar + .content {
max-width: 100%;
}
}
#nav-filter {
width: 100%;
box-sizing: border-box;
padding: 2px 5px;
margin: 5px 0;
border: 1px solid var(--border-color);
background-color: var(--darkened-bg);
color: var(--body-fg);
}
#nav-filter:focus {
border-color: var(--body-quiet-color);
}
#nav-filter.no-results {
background: var(--message-error-bg);
}
#nav-sidebar table {
width: 100%;
}

View file

@ -1,967 +0,0 @@
/* Tablets */
input[type="submit"], button {
-webkit-appearance: none;
appearance: none;
}
@media (max-width: 1024px) {
/* Basic */
html {
-webkit-text-size-adjust: 100%;
}
td, th {
padding: 10px;
font-size: 0.875rem;
}
.small {
font-size: 0.75rem;
}
/* Layout */
#container {
min-width: 0;
}
#content {
padding: 15px 20px 20px;
}
div.breadcrumbs {
padding: 10px 30px;
}
/* Header */
#header {
flex-direction: column;
padding: 15px 30px;
justify-content: flex-start;
}
#site-name {
margin: 0 0 8px;
line-height: 1.2;
}
#user-tools {
margin: 0;
font-weight: 400;
line-height: 1.85;
text-align: left;
}
#user-tools a {
display: inline-block;
line-height: 1.4;
}
/* Dashboard */
.dashboard #content {
width: auto;
}
#content-related {
margin-right: -290px;
}
.colSM #content-related {
margin-left: -290px;
}
.colMS {
margin-right: 290px;
}
.colSM {
margin-left: 290px;
}
.dashboard .module table td a {
padding-right: 0;
}
td .changelink, td .addlink {
font-size: 0.8125rem;
}
/* Changelist */
#toolbar {
border: none;
padding: 15px;
}
#changelist-search > div {
display: flex;
flex-wrap: nowrap;
max-width: 480px;
}
#changelist-search label {
line-height: 1.375rem;
}
#toolbar form #searchbar {
flex: 1 0 auto;
width: 0;
height: 1.375rem;
margin: 0 10px 0 6px;
}
#toolbar form input[type=submit] {
flex: 0 1 auto;
}
#changelist-search .quiet {
width: 0;
flex: 1 0 auto;
margin: 5px 0 0 25px;
}
#changelist .actions {
display: flex;
flex-wrap: wrap;
padding: 15px 0;
}
#changelist .actions label {
display: flex;
}
#changelist .actions select {
background: var(--body-bg);
}
#changelist .actions .button {
min-width: 48px;
margin: 0 10px;
}
#changelist .actions span.all,
#changelist .actions span.clear,
#changelist .actions span.question,
#changelist .actions span.action-counter {
font-size: 0.6875rem;
margin: 0 10px 0 0;
}
#changelist-filter {
flex-basis: 200px;
}
.change-list .filtered .results,
.change-list .filtered .paginator,
.filtered #toolbar,
.filtered .actions,
#changelist .paginator {
border-top-color: var(--hairline-color); /* XXX Is this used at all? */
}
#changelist .results + .paginator {
border-top: none;
}
/* Forms */
label {
font-size: 1rem;
}
/*
Minifiers remove the default (text) "type" attribute from "input" HTML
tags. Add input:not([type]) to make the CSS stylesheet work the same.
*/
.form-row input:not([type]),
.form-row input[type=text],
.form-row input[type=password],
.form-row input[type=email],
.form-row input[type=url],
.form-row input[type=tel],
.form-row input[type=number],
.form-row textarea,
.form-row select,
.form-row .vTextField {
box-sizing: border-box;
margin: 0;
padding: 6px 8px;
min-height: 2.25rem;
font-size: 1rem;
}
.form-row select {
height: 2.25rem;
}
.form-row select[multiple] {
height: auto;
min-height: 0;
}
fieldset .fieldBox + .fieldBox {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--hairline-color);
}
textarea {
max-width: 100%;
max-height: 120px;
}
.aligned label {
padding-top: 6px;
}
.aligned .related-lookup,
.aligned .datetimeshortcuts,
.aligned .related-lookup + strong {
align-self: center;
margin-left: 15px;
}
form .aligned div.radiolist {
margin-left: 2px;
}
.submit-row {
padding: 8px;
}
.submit-row a.deletelink {
padding: 10px 7px;
}
.button, input[type=submit], input[type=button], .submit-row input, a.button {
padding: 7px;
}
/* Selector */
.selector {
display: flex;
width: 100%;
}
.selector .selector-filter {
display: flex;
align-items: center;
}
.selector .selector-filter label {
margin: 0 8px 0 0;
}
.selector .selector-filter input {
width: 100%;
min-height: 0;
flex: 1 1;
}
.selector-available, .selector-chosen {
width: auto;
flex: 1 1;
display: flex;
flex-direction: column;
}
.selector select {
width: 100%;
flex: 1 0 auto;
margin-bottom: 5px;
}
.selector ul.selector-chooser {
width: 26px;
height: 52px;
padding: 2px 0;
border-radius: 20px;
transform: translateY(-10px);
}
.selector-add, .selector-remove {
width: 20px;
height: 20px;
background-size: 20px auto;
}
.selector-add {
background-position: 0 -120px;
}
.selector-remove {
background-position: 0 -80px;
}
a.selector-chooseall, a.selector-clearall {
align-self: center;
}
.stacked {
flex-direction: column;
max-width: 480px;
}
.stacked > * {
flex: 0 1 auto;
}
.stacked select {
margin-bottom: 0;
}
.stacked .selector-available, .stacked .selector-chosen {
width: auto;
}
.stacked ul.selector-chooser {
width: 52px;
height: 26px;
padding: 0 2px;
transform: none;
}
.stacked .selector-chooser li {
padding: 3px;
}
.stacked .selector-add, .stacked .selector-remove {
background-size: 20px auto;
}
.stacked .selector-add {
background-position: 0 -40px;
}
.stacked .active.selector-add {
background-position: 0 -40px;
}
.active.selector-add:focus, .active.selector-add:hover {
background-position: 0 -140px;
}
.stacked .active.selector-add:focus, .stacked .active.selector-add:hover {
background-position: 0 -60px;
}
.stacked .selector-remove {
background-position: 0 0;
}
.stacked .active.selector-remove {
background-position: 0 0;
}
.active.selector-remove:focus, .active.selector-remove:hover {
background-position: 0 -100px;
}
.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover {
background-position: 0 -20px;
}
.help-tooltip, .selector .help-icon {
display: none;
}
.datetime input {
width: 50%;
max-width: 120px;
}
.datetime span {
font-size: 0.8125rem;
}
.datetime .timezonewarning {
display: block;
font-size: 0.6875rem;
color: var(--body-quiet-color);
}
.datetimeshortcuts {
color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */
}
.form-row .datetime input.vDateField, .form-row .datetime input.vTimeField {
width: 75%;
}
.inline-group {
overflow: auto;
}
/* Messages */
ul.messagelist li {
padding-left: 55px;
background-position: 30px 12px;
}
ul.messagelist li.error {
background-position: 30px 12px;
}
ul.messagelist li.warning {
background-position: 30px 14px;
}
/* Login */
.login #header {
padding: 15px 20px;
}
.login #site-name {
margin: 0;
}
/* GIS */
div.olMap {
max-width: calc(100vw - 30px);
max-height: 300px;
}
.olMap + .clear_features {
display: block;
margin-top: 10px;
}
/* Docs */
.module table.xfull {
width: 100%;
}
pre.literal-block {
overflow: auto;
}
}
/* Mobile */
@media (max-width: 767px) {
/* Layout */
#header, #content {
padding: 15px;
}
div.breadcrumbs {
padding: 10px 15px;
}
/* Dashboard */
.colMS, .colSM {
margin: 0;
}
#content-related, .colSM #content-related {
width: 100%;
margin: 0;
}
#content-related .module {
margin-bottom: 0;
}
#content-related .module h2 {
padding: 10px 15px;
font-size: 1rem;
}
/* Changelist */
#changelist {
align-items: stretch;
flex-direction: column;
}
#toolbar {
padding: 10px;
}
#changelist-filter {
margin-left: 0;
}
#changelist .actions label {
flex: 1 1;
}
#changelist .actions select {
flex: 1 0;
width: 100%;
}
#changelist .actions span {
flex: 1 0 100%;
}
#changelist-filter {
position: static;
width: auto;
margin-top: 30px;
}
.object-tools {
float: none;
margin: 0 0 15px;
padding: 0;
overflow: hidden;
}
.object-tools li {
height: auto;
margin-left: 0;
}
.object-tools li + li {
margin-left: 15px;
}
/* Forms */
.form-row {
padding: 15px 0;
}
.aligned .form-row,
.aligned .form-row > div {
max-width: 100vw;
}
.aligned .form-row > div {
width: calc(100vw - 30px);
}
.flex-container {
flex-flow: column;
}
.flex-container.checkbox-row {
flex-flow: row;
}
textarea {
max-width: none;
}
.vURLField {
width: auto;
}
fieldset .fieldBox + .fieldBox {
margin-top: 15px;
padding-top: 15px;
}
.aligned label {
width: 100%;
min-width: auto;
padding: 0 0 10px;
}
.aligned label:after {
max-height: 0;
}
.aligned .form-row input,
.aligned .form-row select,
.aligned .form-row textarea {
flex: 1 1 auto;
max-width: 100%;
}
.aligned .checkbox-row input {
flex: 0 1 auto;
margin: 0;
}
.aligned .vCheckboxLabel {
flex: 1 0;
padding: 1px 0 0 5px;
}
.aligned label + p,
.aligned label + div.help,
.aligned label + div.readonly {
padding: 0;
margin-left: 0;
}
.aligned p.file-upload {
font-size: 0.8125rem;
}
span.clearable-file-input {
margin-left: 15px;
}
span.clearable-file-input label {
font-size: 0.8125rem;
padding-bottom: 0;
}
.aligned .timezonewarning {
flex: 1 0 100%;
margin-top: 5px;
}
form .aligned .form-row div.help {
width: 100%;
margin: 5px 0 0;
padding: 0;
}
form .aligned ul,
form .aligned ul.errorlist {
margin-left: 0;
padding-left: 0;
}
form .aligned div.radiolist {
margin-top: 5px;
margin-right: 15px;
margin-bottom: -3px;
}
form .aligned div.radiolist:not(.inline) div + div {
margin-top: 5px;
}
/* Related widget */
.related-widget-wrapper {
width: 100%;
display: flex;
align-items: flex-start;
}
.related-widget-wrapper .selector {
order: 1;
}
.related-widget-wrapper > a {
order: 2;
}
.related-widget-wrapper .radiolist ~ a {
align-self: flex-end;
}
.related-widget-wrapper > select ~ a {
align-self: center;
}
/* Selector */
.selector {
flex-direction: column;
gap: 10px 0;
}
.selector-available, .selector-chosen {
flex: 1 1 auto;
}
.selector select {
max-height: 96px;
}
.selector ul.selector-chooser {
display: block;
width: 52px;
height: 26px;
padding: 0 2px;
transform: none;
}
.selector ul.selector-chooser li {
float: left;
}
.selector-remove {
background-position: 0 0;
}
.active.selector-remove:focus, .active.selector-remove:hover {
background-position: 0 -20px;
}
.selector-add {
background-position: 0 -40px;
}
.active.selector-add:focus, .active.selector-add:hover {
background-position: 0 -60px;
}
/* Inlines */
.inline-group[data-inline-type="stacked"] .inline-related {
border: 1px solid var(--hairline-color);
border-radius: 4px;
margin-top: 15px;
overflow: auto;
}
.inline-group[data-inline-type="stacked"] .inline-related > * {
box-sizing: border-box;
}
.inline-group[data-inline-type="stacked"] .inline-related .module {
padding: 0 10px;
}
.inline-group[data-inline-type="stacked"] .inline-related .module .form-row {
border-top: 1px solid var(--hairline-color);
border-bottom: none;
}
.inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child {
border-top: none;
}
.inline-group[data-inline-type="stacked"] .inline-related h3 {
padding: 10px;
border-top-width: 0;
border-bottom-width: 2px;
display: flex;
flex-wrap: wrap;
align-items: center;
}
.inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label {
margin-right: auto;
}
.inline-group[data-inline-type="stacked"] .inline-related h3 span.delete {
float: none;
flex: 1 1 100%;
margin-top: 5px;
}
.inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) {
width: 100%;
}
.inline-group[data-inline-type="stacked"] .aligned label {
width: 100%;
}
.inline-group[data-inline-type="stacked"] div.add-row {
margin-top: 15px;
border: 1px solid var(--hairline-color);
border-radius: 4px;
}
.inline-group div.add-row,
.inline-group .tabular tr.add-row td {
padding: 0;
}
.inline-group div.add-row a,
.inline-group .tabular tr.add-row td a {
display: block;
padding: 8px 10px 8px 26px;
background-position: 8px 9px;
}
/* Submit row */
.submit-row {
padding: 10px;
margin: 0 0 15px;
flex-direction: column;
gap: 8px;
}
.submit-row input, .submit-row input.default, .submit-row a {
text-align: center;
}
.submit-row a.closelink {
padding: 10px 0;
text-align: center;
}
.submit-row a.deletelink {
margin: 0;
}
/* Messages */
ul.messagelist li {
padding-left: 40px;
background-position: 15px 12px;
}
ul.messagelist li.error {
background-position: 15px 12px;
}
ul.messagelist li.warning {
background-position: 15px 14px;
}
/* Paginator */
.paginator .this-page, .paginator a:link, .paginator a:visited {
padding: 4px 10px;
}
/* Login */
body.login {
padding: 0 15px;
}
.login #container {
width: auto;
max-width: 480px;
margin: 50px auto;
}
.login #header,
.login #content {
padding: 15px;
}
.login #content-main {
float: none;
}
.login .form-row {
padding: 0;
}
.login .form-row + .form-row {
margin-top: 15px;
}
.login .form-row label {
margin: 0 0 5px;
line-height: 1.2;
}
.login .submit-row {
padding: 15px 0 0;
}
.login br {
display: none;
}
.login .submit-row input {
margin: 0;
text-transform: uppercase;
}
.errornote {
margin: 0 0 20px;
padding: 8px 12px;
font-size: 0.8125rem;
}
/* Calendar and clock */
.calendarbox, .clockbox {
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%);
margin: 0;
border: none;
overflow: visible;
}
.calendarbox:before, .clockbox:before {
content: '';
position: fixed;
top: 50%;
left: 50%;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.75);
transform: translate(-50%, -50%);
}
.calendarbox > *, .clockbox > * {
position: relative;
z-index: 1;
}
.calendarbox > div:first-child {
z-index: 2;
}
.calendarbox .calendar, .clockbox h2 {
border-radius: 4px 4px 0 0;
overflow: hidden;
}
.calendarbox .calendar-cancel, .clockbox .calendar-cancel {
border-radius: 0 0 4px 4px;
overflow: hidden;
}
.calendar-shortcuts {
padding: 10px 0;
font-size: 0.75rem;
line-height: 0.75rem;
}
.calendar-shortcuts a {
margin: 0 4px;
}
.timelist a {
background: var(--body-bg);
padding: 4px;
}
.calendar-cancel {
padding: 8px 10px;
}
.clockbox h2 {
padding: 8px 15px;
}
.calendar caption {
padding: 10px;
}
.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next {
z-index: 1;
top: 10px;
}
/* History */
table#change-history tbody th, table#change-history tbody td {
font-size: 0.8125rem;
word-break: break-word;
}
table#change-history tbody th {
width: auto;
}
/* Docs */
table.model tbody th, table.model tbody td {
font-size: 0.8125rem;
word-break: break-word;
}
}

View file

@ -1,111 +0,0 @@
/* TABLETS */
@media (max-width: 1024px) {
[dir="rtl"] .colMS {
margin-right: 0;
}
[dir="rtl"] #user-tools {
text-align: right;
}
[dir="rtl"] #changelist .actions label {
padding-left: 10px;
padding-right: 0;
}
[dir="rtl"] #changelist .actions select {
margin-left: 0;
margin-right: 15px;
}
[dir="rtl"] .change-list .filtered .results,
[dir="rtl"] .change-list .filtered .paginator,
[dir="rtl"] .filtered #toolbar,
[dir="rtl"] .filtered div.xfull,
[dir="rtl"] .filtered .actions,
[dir="rtl"] #changelist-filter {
margin-left: 0;
}
[dir="rtl"] .inline-group ul.tools a.add,
[dir="rtl"] .inline-group div.add-row a,
[dir="rtl"] .inline-group .tabular tr.add-row td a {
padding: 8px 26px 8px 10px;
background-position: calc(100% - 8px) 9px;
}
[dir="rtl"] .selector .selector-filter label {
margin-right: 0;
margin-left: 8px;
}
[dir="rtl"] .object-tools li {
float: right;
}
[dir="rtl"] .object-tools li + li {
margin-left: 0;
margin-right: 15px;
}
[dir="rtl"] .dashboard .module table td a {
padding-left: 0;
padding-right: 16px;
}
[dir="rtl"] .selector-add {
background-position: 0 -80px;
}
[dir="rtl"] .selector-remove {
background-position: 0 -120px;
}
[dir="rtl"] .active.selector-add:focus, .active.selector-add:hover {
background-position: 0 -100px;
}
[dir="rtl"] .active.selector-remove:focus, .active.selector-remove:hover {
background-position: 0 -140px;
}
}
/* MOBILE */
@media (max-width: 767px) {
[dir="rtl"] .aligned .related-lookup,
[dir="rtl"] .aligned .datetimeshortcuts {
margin-left: 0;
margin-right: 15px;
}
[dir="rtl"] .aligned ul,
[dir="rtl"] form .aligned ul.errorlist {
margin-right: 0;
}
[dir="rtl"] #changelist-filter {
margin-left: 0;
margin-right: 0;
}
[dir="rtl"] .aligned .vCheckboxLabel {
padding: 1px 5px 0 0;
}
[dir="rtl"] .selector-remove {
background-position: 0 0;
}
[dir="rtl"] .active.selector-remove:focus, .active.selector-remove:hover {
background-position: 0 -20px;
}
[dir="rtl"] .selector-add {
background-position: 0 -40px;
}
[dir="rtl"] .active.selector-add:focus, .active.selector-add:hover {
background-position: 0 -60px;
}
}

View file

@ -1,291 +0,0 @@
/* GLOBAL */
th {
text-align: right;
}
.module h2, .module caption {
text-align: right;
}
.module ul, .module ol {
margin-left: 0;
margin-right: 1.5em;
}
.viewlink, .addlink, .changelink, .hidelink {
padding-left: 0;
padding-right: 16px;
background-position: 100% 1px;
}
.deletelink {
padding-left: 0;
padding-right: 16px;
background-position: 100% 1px;
}
.object-tools {
float: left;
}
thead th:first-child,
tfoot td:first-child {
border-left: none;
}
/* LAYOUT */
#user-tools {
right: auto;
left: 0;
text-align: left;
}
div.breadcrumbs {
text-align: right;
}
#content-main {
float: right;
}
#content-related {
float: left;
margin-left: -300px;
margin-right: auto;
}
.colMS {
margin-left: 300px;
margin-right: 0;
}
/* SORTABLE TABLES */
table thead th.sorted .sortoptions {
float: left;
}
thead th.sorted .text {
padding-right: 0;
padding-left: 42px;
}
/* dashboard styles */
.dashboard .module table td a {
padding-left: .6em;
padding-right: 16px;
}
/* changelists styles */
.change-list .filtered table {
border-left: none;
border-right: 0px none;
}
#changelist-filter {
border-left: none;
border-right: none;
margin-left: 0;
margin-right: 30px;
}
#changelist-filter li.selected {
border-left: none;
padding-left: 10px;
margin-left: 0;
border-right: 5px solid var(--hairline-color);
padding-right: 10px;
margin-right: -15px;
}
#changelist table tbody td:first-child, #changelist table tbody th:first-child {
border-right: none;
border-left: none;
}
.paginator .end {
margin-left: 6px;
margin-right: 0;
}
.paginator input {
margin-left: 0;
margin-right: auto;
}
/* FORMS */
.aligned label {
padding: 0 0 3px 1em;
}
.submit-row a.deletelink {
margin-left: 0;
margin-right: auto;
}
.vDateField, .vTimeField {
margin-left: 2px;
}
.aligned .form-row input {
margin-left: 5px;
}
form .aligned ul {
margin-right: 163px;
padding-right: 10px;
margin-left: 0;
padding-left: 0;
}
form ul.inline li {
float: right;
padding-right: 0;
padding-left: 7px;
}
form .aligned p.help,
form .aligned div.help {
margin-left: 0;
margin-right: 160px;
padding-right: 10px;
}
form div.help ul,
form .aligned .checkbox-row + .help,
form .aligned p.date div.help.timezonewarning,
form .aligned p.datetime div.help.timezonewarning,
form .aligned p.time div.help.timezonewarning {
margin-right: 0;
padding-right: 0;
}
form .wide p.help,
form .wide ul.errorlist,
form .wide div.help {
padding-left: 0;
padding-right: 50px;
}
.submit-row {
text-align: right;
}
fieldset .fieldBox {
margin-left: 20px;
margin-right: 0;
}
.errorlist li {
background-position: 100% 12px;
padding: 0;
}
.errornote {
background-position: 100% 12px;
padding: 10px 12px;
}
/* WIDGETS */
.calendarnav-previous {
top: 0;
left: auto;
right: 10px;
background: url(../img/calendar-icons.svg) 0 -15px no-repeat;
}
.calendarnav-next {
top: 0;
right: auto;
left: 10px;
background: url(../img/calendar-icons.svg) 0 0 no-repeat;
}
.calendar caption, .calendarbox h2 {
text-align: center;
}
.selector {
float: right;
}
.selector .selector-filter {
text-align: right;
}
.selector-add {
background: url(../img/selector-icons.svg) 0 -64px no-repeat;
}
.active.selector-add:focus, .active.selector-add:hover {
background-position: 0 -80px;
}
.selector-remove {
background: url(../img/selector-icons.svg) 0 -96px no-repeat;
}
.active.selector-remove:focus, .active.selector-remove:hover {
background-position: 0 -112px;
}
a.selector-chooseall {
background: url(../img/selector-icons.svg) right -128px no-repeat;
}
a.active.selector-chooseall:focus, a.active.selector-chooseall:hover {
background-position: 100% -144px;
}
a.selector-clearall {
background: url(../img/selector-icons.svg) 0 -160px no-repeat;
}
a.active.selector-clearall:focus, a.active.selector-clearall:hover {
background-position: 0 -176px;
}
.inline-deletelink {
float: left;
}
form .form-row p.datetime {
overflow: hidden;
}
.related-widget-wrapper {
float: right;
}
/* MISC */
.inline-related h2, .inline-group h2 {
text-align: right
}
.inline-related h3 span.delete {
padding-right: 20px;
padding-left: inherit;
left: 10px;
right: inherit;
float:left;
}
.inline-related h3 span.delete label {
margin-left: inherit;
margin-right: 2px;
}
.inline-group .tabular td.original p {
right: 0;
}
.selector .selector-chooser {
margin: 0;
}

View file

@ -1,19 +0,0 @@
/* Hide warnings fields if usable password is selected */
form:has(#id_usable_password input[value="true"]:checked) .messagelist {
display: none;
}
/* Hide password fields if unusable password is selected */
form:has(#id_usable_password input[value="false"]:checked) .field-password1,
form:has(#id_usable_password input[value="false"]:checked) .field-password2 {
display: none;
}
/* Select appropriate submit button */
form:has(#id_usable_password input[value="true"]:checked) input[type="submit"].unset-password {
display: none;
}
form:has(#id_usable_password input[value="false"]:checked) input[type="submit"].set-password {
display: none;
}

View file

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,481 +0,0 @@
.select2-container {
box-sizing: border-box;
display: inline-block;
margin: 0;
position: relative;
vertical-align: middle; }
.select2-container .select2-selection--single {
box-sizing: border-box;
cursor: pointer;
display: block;
height: 28px;
user-select: none;
-webkit-user-select: none; }
.select2-container .select2-selection--single .select2-selection__rendered {
display: block;
padding-left: 8px;
padding-right: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; }
.select2-container .select2-selection--single .select2-selection__clear {
position: relative; }
.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered {
padding-right: 8px;
padding-left: 20px; }
.select2-container .select2-selection--multiple {
box-sizing: border-box;
cursor: pointer;
display: block;
min-height: 32px;
user-select: none;
-webkit-user-select: none; }
.select2-container .select2-selection--multiple .select2-selection__rendered {
display: inline-block;
overflow: hidden;
padding-left: 8px;
text-overflow: ellipsis;
white-space: nowrap; }
.select2-container .select2-search--inline {
float: left; }
.select2-container .select2-search--inline .select2-search__field {
box-sizing: border-box;
border: none;
font-size: 100%;
margin-top: 5px;
padding: 0; }
.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button {
-webkit-appearance: none; }
.select2-dropdown {
background-color: white;
border: 1px solid #aaa;
border-radius: 4px;
box-sizing: border-box;
display: block;
position: absolute;
left: -100000px;
width: 100%;
z-index: 1051; }
.select2-results {
display: block; }
.select2-results__options {
list-style: none;
margin: 0;
padding: 0; }
.select2-results__option {
padding: 6px;
user-select: none;
-webkit-user-select: none; }
.select2-results__option[aria-selected] {
cursor: pointer; }
.select2-container--open .select2-dropdown {
left: 0; }
.select2-container--open .select2-dropdown--above {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0; }
.select2-container--open .select2-dropdown--below {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0; }
.select2-search--dropdown {
display: block;
padding: 4px; }
.select2-search--dropdown .select2-search__field {
padding: 4px;
width: 100%;
box-sizing: border-box; }
.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button {
-webkit-appearance: none; }
.select2-search--dropdown.select2-search--hide {
display: none; }
.select2-close-mask {
border: 0;
margin: 0;
padding: 0;
display: block;
position: fixed;
left: 0;
top: 0;
min-height: 100%;
min-width: 100%;
height: auto;
width: auto;
opacity: 0;
z-index: 99;
background-color: #fff;
filter: alpha(opacity=0); }
.select2-hidden-accessible {
border: 0 !important;
clip: rect(0 0 0 0) !important;
-webkit-clip-path: inset(50%) !important;
clip-path: inset(50%) !important;
height: 1px !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
width: 1px !important;
white-space: nowrap !important; }
.select2-container--default .select2-selection--single {
background-color: #fff;
border: 1px solid #aaa;
border-radius: 4px; }
.select2-container--default .select2-selection--single .select2-selection__rendered {
color: #444;
line-height: 28px; }
.select2-container--default .select2-selection--single .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold; }
.select2-container--default .select2-selection--single .select2-selection__placeholder {
color: #999; }
.select2-container--default .select2-selection--single .select2-selection__arrow {
height: 26px;
position: absolute;
top: 1px;
right: 1px;
width: 20px; }
.select2-container--default .select2-selection--single .select2-selection__arrow b {
border-color: #888 transparent transparent transparent;
border-style: solid;
border-width: 5px 4px 0 4px;
height: 0;
left: 50%;
margin-left: -4px;
margin-top: -2px;
position: absolute;
top: 50%;
width: 0; }
.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear {
float: left; }
.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow {
left: 1px;
right: auto; }
.select2-container--default.select2-container--disabled .select2-selection--single {
background-color: #eee;
cursor: default; }
.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear {
display: none; }
.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #888 transparent;
border-width: 0 4px 5px 4px; }
.select2-container--default .select2-selection--multiple {
background-color: white;
border: 1px solid #aaa;
border-radius: 4px;
cursor: text; }
.select2-container--default .select2-selection--multiple .select2-selection__rendered {
box-sizing: border-box;
list-style: none;
margin: 0;
padding: 0 5px;
width: 100%; }
.select2-container--default .select2-selection--multiple .select2-selection__rendered li {
list-style: none; }
.select2-container--default .select2-selection--multiple .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
margin-top: 5px;
margin-right: 10px;
padding: 1px; }
.select2-container--default .select2-selection--multiple .select2-selection__choice {
background-color: #e4e4e4;
border: 1px solid #aaa;
border-radius: 4px;
cursor: default;
float: left;
margin-right: 5px;
margin-top: 5px;
padding: 0 5px; }
.select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
color: #999;
cursor: pointer;
display: inline-block;
font-weight: bold;
margin-right: 2px; }
.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover {
color: #333; }
.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline {
float: right; }
.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
margin-left: 5px;
margin-right: auto; }
.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
margin-left: 2px;
margin-right: auto; }
.select2-container--default.select2-container--focus .select2-selection--multiple {
border: solid black 1px;
outline: 0; }
.select2-container--default.select2-container--disabled .select2-selection--multiple {
background-color: #eee;
cursor: default; }
.select2-container--default.select2-container--disabled .select2-selection__choice__remove {
display: none; }
.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple {
border-top-left-radius: 0;
border-top-right-radius: 0; }
.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0; }
.select2-container--default .select2-search--dropdown .select2-search__field {
border: 1px solid #aaa; }
.select2-container--default .select2-search--inline .select2-search__field {
background: transparent;
border: none;
outline: 0;
box-shadow: none;
-webkit-appearance: textfield; }
.select2-container--default .select2-results > .select2-results__options {
max-height: 200px;
overflow-y: auto; }
.select2-container--default .select2-results__option[role=group] {
padding: 0; }
.select2-container--default .select2-results__option[aria-disabled=true] {
color: #999; }
.select2-container--default .select2-results__option[aria-selected=true] {
background-color: #ddd; }
.select2-container--default .select2-results__option .select2-results__option {
padding-left: 1em; }
.select2-container--default .select2-results__option .select2-results__option .select2-results__group {
padding-left: 0; }
.select2-container--default .select2-results__option .select2-results__option .select2-results__option {
margin-left: -1em;
padding-left: 2em; }
.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -2em;
padding-left: 3em; }
.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -3em;
padding-left: 4em; }
.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -4em;
padding-left: 5em; }
.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -5em;
padding-left: 6em; }
.select2-container--default .select2-results__option--highlighted[aria-selected] {
background-color: #5897fb;
color: white; }
.select2-container--default .select2-results__group {
cursor: default;
display: block;
padding: 6px; }
.select2-container--classic .select2-selection--single {
background-color: #f7f7f7;
border: 1px solid #aaa;
border-radius: 4px;
outline: 0;
background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%);
background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%);
background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); }
.select2-container--classic .select2-selection--single:focus {
border: 1px solid #5897fb; }
.select2-container--classic .select2-selection--single .select2-selection__rendered {
color: #444;
line-height: 28px; }
.select2-container--classic .select2-selection--single .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
margin-right: 10px; }
.select2-container--classic .select2-selection--single .select2-selection__placeholder {
color: #999; }
.select2-container--classic .select2-selection--single .select2-selection__arrow {
background-color: #ddd;
border: none;
border-left: 1px solid #aaa;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
height: 26px;
position: absolute;
top: 1px;
right: 1px;
width: 20px;
background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%);
background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%);
background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); }
.select2-container--classic .select2-selection--single .select2-selection__arrow b {
border-color: #888 transparent transparent transparent;
border-style: solid;
border-width: 5px 4px 0 4px;
height: 0;
left: 50%;
margin-left: -4px;
margin-top: -2px;
position: absolute;
top: 50%;
width: 0; }
.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear {
float: left; }
.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow {
border: none;
border-right: 1px solid #aaa;
border-radius: 0;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
left: 1px;
right: auto; }
.select2-container--classic.select2-container--open .select2-selection--single {
border: 1px solid #5897fb; }
.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow {
background: transparent;
border: none; }
.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #888 transparent;
border-width: 0 4px 5px 4px; }
.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%);
background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%);
background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); }
.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%);
background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%);
background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); }
.select2-container--classic .select2-selection--multiple {
background-color: white;
border: 1px solid #aaa;
border-radius: 4px;
cursor: text;
outline: 0; }
.select2-container--classic .select2-selection--multiple:focus {
border: 1px solid #5897fb; }
.select2-container--classic .select2-selection--multiple .select2-selection__rendered {
list-style: none;
margin: 0;
padding: 0 5px; }
.select2-container--classic .select2-selection--multiple .select2-selection__clear {
display: none; }
.select2-container--classic .select2-selection--multiple .select2-selection__choice {
background-color: #e4e4e4;
border: 1px solid #aaa;
border-radius: 4px;
cursor: default;
float: left;
margin-right: 5px;
margin-top: 5px;
padding: 0 5px; }
.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove {
color: #888;
cursor: pointer;
display: inline-block;
font-weight: bold;
margin-right: 2px; }
.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover {
color: #555; }
.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
float: right;
margin-left: 5px;
margin-right: auto; }
.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
margin-left: 2px;
margin-right: auto; }
.select2-container--classic.select2-container--open .select2-selection--multiple {
border: 1px solid #5897fb; }
.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0; }
.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0; }
.select2-container--classic .select2-search--dropdown .select2-search__field {
border: 1px solid #aaa;
outline: 0; }
.select2-container--classic .select2-search--inline .select2-search__field {
outline: 0;
box-shadow: none; }
.select2-container--classic .select2-dropdown {
background-color: white;
border: 1px solid transparent; }
.select2-container--classic .select2-dropdown--above {
border-bottom: none; }
.select2-container--classic .select2-dropdown--below {
border-top: none; }
.select2-container--classic .select2-results > .select2-results__options {
max-height: 200px;
overflow-y: auto; }
.select2-container--classic .select2-results__option[role=group] {
padding: 0; }
.select2-container--classic .select2-results__option[aria-disabled=true] {
color: grey; }
.select2-container--classic .select2-results__option--highlighted[aria-selected] {
background-color: #3875d7;
color: white; }
.select2-container--classic .select2-results__group {
cursor: default;
display: block;
padding: 6px; }
.select2-container--classic.select2-container--open .select2-dropdown {
border-color: #5897fb; }

File diff suppressed because one or more lines are too long

View file

@ -1,593 +0,0 @@
/* SELECTOR (FILTER INTERFACE) */
.selector {
display: flex;
flex-grow: 1;
gap: 0 10px;
}
.selector select {
height: 17.2em;
flex: 1 0 auto;
overflow: scroll;
width: 100%;
}
.selector-available, .selector-chosen {
text-align: center;
display: flex;
flex-direction: column;
flex: 1 1;
}
.selector-available h2, .selector-chosen h2 {
border: 1px solid var(--border-color);
border-radius: 4px 4px 0 0;
}
.selector-chosen .list-footer-display {
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 4px 4px;
margin: 0 0 10px;
padding: 8px;
text-align: center;
background: var(--primary);
color: var(--header-link-color);
cursor: pointer;
}
.selector-chosen .list-footer-display__clear {
color: var(--breadcrumbs-fg);
}
.selector-chosen h2 {
background: var(--secondary);
color: var(--header-link-color);
}
.selector .selector-available h2 {
background: var(--darkened-bg);
color: var(--body-quiet-color);
}
.selector .selector-filter {
border: 1px solid var(--border-color);
border-width: 0 1px;
padding: 8px;
color: var(--body-quiet-color);
font-size: 0.625rem;
margin: 0;
text-align: left;
display: flex;
}
.selector .selector-filter label,
.inline-group .aligned .selector .selector-filter label {
float: left;
margin: 7px 0 0;
width: 18px;
height: 18px;
padding: 0;
overflow: hidden;
line-height: 1;
min-width: auto;
}
.selector-filter input {
flex-grow: 1;
}
.selector .selector-available input,
.selector .selector-chosen input {
margin-left: 8px;
}
.selector ul.selector-chooser {
align-self: center;
width: 22px;
background-color: var(--selected-bg);
border-radius: 10px;
margin: 0;
padding: 0;
transform: translateY(-17px);
}
.selector-chooser li {
margin: 0;
padding: 3px;
list-style-type: none;
}
.selector select {
padding: 0 10px;
margin: 0 0 10px;
border-radius: 0 0 4px 4px;
}
.selector .selector-chosen--with-filtered select {
margin: 0;
border-radius: 0;
height: 14em;
}
.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display {
display: none;
}
.selector-add, .selector-remove {
width: 16px;
height: 16px;
display: block;
text-indent: -3000px;
overflow: hidden;
cursor: default;
opacity: 0.55;
}
.active.selector-add, .active.selector-remove {
opacity: 1;
}
.active.selector-add:hover, .active.selector-remove:hover {
cursor: pointer;
}
.selector-add {
background: url(../img/selector-icons.svg) 0 -96px no-repeat;
}
.active.selector-add:focus, .active.selector-add:hover {
background-position: 0 -112px;
}
.selector-remove {
background: url(../img/selector-icons.svg) 0 -64px no-repeat;
}
.active.selector-remove:focus, .active.selector-remove:hover {
background-position: 0 -80px;
}
a.selector-chooseall, a.selector-clearall {
display: inline-block;
height: 16px;
text-align: left;
margin: 0 auto;
overflow: hidden;
font-weight: bold;
line-height: 16px;
color: var(--body-quiet-color);
text-decoration: none;
opacity: 0.55;
}
a.active.selector-chooseall:focus, a.active.selector-clearall:focus,
a.active.selector-chooseall:hover, a.active.selector-clearall:hover {
color: var(--link-fg);
}
a.active.selector-chooseall, a.active.selector-clearall {
opacity: 1;
}
a.active.selector-chooseall:hover, a.active.selector-clearall:hover {
cursor: pointer;
}
a.selector-chooseall {
padding: 0 18px 0 0;
background: url(../img/selector-icons.svg) right -160px no-repeat;
cursor: default;
}
a.active.selector-chooseall:focus, a.active.selector-chooseall:hover {
background-position: 100% -176px;
}
a.selector-clearall {
padding: 0 0 0 18px;
background: url(../img/selector-icons.svg) 0 -128px no-repeat;
cursor: default;
}
a.active.selector-clearall:focus, a.active.selector-clearall:hover {
background-position: 0 -144px;
}
/* STACKED SELECTORS */
.stacked {
float: left;
width: 490px;
display: block;
}
.stacked select {
width: 480px;
height: 10.1em;
}
.stacked .selector-available, .stacked .selector-chosen {
width: 480px;
}
.stacked .selector-available {
margin-bottom: 0;
}
.stacked .selector-available input {
width: 422px;
}
.stacked ul.selector-chooser {
height: 22px;
width: 50px;
margin: 0 0 10px 40%;
background-color: #eee;
border-radius: 10px;
transform: none;
}
.stacked .selector-chooser li {
float: left;
padding: 3px 3px 3px 5px;
}
.stacked .selector-chooseall, .stacked .selector-clearall {
display: none;
}
.stacked .selector-add {
background: url(../img/selector-icons.svg) 0 -32px no-repeat;
cursor: default;
}
.stacked .active.selector-add {
background-position: 0 -32px;
cursor: pointer;
}
.stacked .active.selector-add:focus, .stacked .active.selector-add:hover {
background-position: 0 -48px;
cursor: pointer;
}
.stacked .selector-remove {
background: url(../img/selector-icons.svg) 0 0 no-repeat;
cursor: default;
}
.stacked .active.selector-remove {
background-position: 0 0px;
cursor: pointer;
}
.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover {
background-position: 0 -16px;
cursor: pointer;
}
.selector .help-icon {
background: url(../img/icon-unknown.svg) 0 0 no-repeat;
display: inline-block;
vertical-align: middle;
margin: -2px 0 0 2px;
width: 13px;
height: 13px;
}
.selector .selector-chosen .help-icon {
background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat;
}
.selector .search-label-icon {
background: url(../img/search.svg) 0 0 no-repeat;
display: inline-block;
height: 1.125rem;
width: 1.125rem;
}
/* DATE AND TIME */
p.datetime {
line-height: 20px;
margin: 0;
padding: 0;
color: var(--body-quiet-color);
font-weight: bold;
}
.datetime span {
white-space: nowrap;
font-weight: normal;
font-size: 0.6875rem;
color: var(--body-quiet-color);
}
.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField {
margin-left: 5px;
margin-bottom: 4px;
}
table p.datetime {
font-size: 0.6875rem;
margin-left: 0;
padding-left: 0;
}
.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon {
position: relative;
display: inline-block;
vertical-align: middle;
height: 16px;
width: 16px;
overflow: hidden;
}
.datetimeshortcuts .clock-icon {
background: url(../img/icon-clock.svg) 0 0 no-repeat;
}
.datetimeshortcuts a:focus .clock-icon,
.datetimeshortcuts a:hover .clock-icon {
background-position: 0 -16px;
}
.datetimeshortcuts .date-icon {
background: url(../img/icon-calendar.svg) 0 0 no-repeat;
top: -1px;
}
.datetimeshortcuts a:focus .date-icon,
.datetimeshortcuts a:hover .date-icon {
background-position: 0 -16px;
}
.timezonewarning {
font-size: 0.6875rem;
color: var(--body-quiet-color);
}
/* URL */
p.url {
line-height: 20px;
margin: 0;
padding: 0;
color: var(--body-quiet-color);
font-size: 0.6875rem;
font-weight: bold;
}
.url a {
font-weight: normal;
}
/* FILE UPLOADS */
p.file-upload {
line-height: 20px;
margin: 0;
padding: 0;
color: var(--body-quiet-color);
font-size: 0.6875rem;
font-weight: bold;
}
.file-upload a {
font-weight: normal;
}
.file-upload .deletelink {
margin-left: 5px;
}
span.clearable-file-input label {
color: var(--body-fg);
font-size: 0.6875rem;
display: inline;
float: none;
}
/* CALENDARS & CLOCKS */
.calendarbox, .clockbox {
margin: 5px auto;
font-size: 0.75rem;
width: 19em;
text-align: center;
background: var(--body-bg);
color: var(--body-fg);
border: 1px solid var(--hairline-color);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
overflow: hidden;
position: relative;
}
.clockbox {
width: auto;
}
.calendar {
margin: 0;
padding: 0;
}
.calendar table {
margin: 0;
padding: 0;
border-collapse: collapse;
background: white;
width: 100%;
}
.calendar caption, .calendarbox h2 {
margin: 0;
text-align: center;
border-top: none;
font-weight: 700;
font-size: 0.75rem;
color: #333;
background: var(--accent);
}
.calendar th {
padding: 8px 5px;
background: var(--darkened-bg);
border-bottom: 1px solid var(--border-color);
font-weight: 400;
font-size: 0.75rem;
text-align: center;
color: var(--body-quiet-color);
}
.calendar td {
font-weight: 400;
font-size: 0.75rem;
text-align: center;
padding: 0;
border-top: 1px solid var(--hairline-color);
border-bottom: none;
}
.calendar td.selected a {
background: var(--secondary);
color: var(--button-fg);
}
.calendar td.nonday {
background: var(--darkened-bg);
}
.calendar td.today a {
font-weight: 700;
}
.calendar td a, .timelist a {
display: block;
font-weight: 400;
padding: 6px;
text-decoration: none;
color: var(--body-quiet-color);
}
.calendar td a:focus, .timelist a:focus,
.calendar td a:hover, .timelist a:hover {
background: var(--primary);
color: white;
}
.calendar td a:active, .timelist a:active {
background: var(--header-bg);
color: white;
}
.calendarnav {
font-size: 0.625rem;
text-align: center;
color: #ccc;
margin: 0;
padding: 1px 3px;
}
.calendarnav a:link, #calendarnav a:visited,
#calendarnav a:focus, #calendarnav a:hover {
color: var(--body-quiet-color);
}
.calendar-shortcuts {
background: var(--body-bg);
color: var(--body-quiet-color);
font-size: 0.6875rem;
line-height: 0.6875rem;
border-top: 1px solid var(--hairline-color);
padding: 8px 0;
}
.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next {
display: block;
position: absolute;
top: 8px;
width: 15px;
height: 15px;
text-indent: -9999px;
padding: 0;
}
.calendarnav-previous {
left: 10px;
background: url(../img/calendar-icons.svg) 0 0 no-repeat;
}
.calendarnav-next {
right: 10px;
background: url(../img/calendar-icons.svg) 0 -15px no-repeat;
}
.calendar-cancel {
margin: 0;
padding: 4px 0;
font-size: 0.75rem;
background: var(--close-button-bg);
border-top: 1px solid var(--border-color);
color: var(--button-fg);
}
.calendar-cancel:focus, .calendar-cancel:hover {
background: var(--close-button-hover-bg);
}
.calendar-cancel a {
color: var(--button-fg);
display: block;
}
ul.timelist, .timelist li {
list-style-type: none;
margin: 0;
padding: 0;
}
.timelist a {
padding: 2px;
}
/* EDIT INLINE */
.inline-deletelink {
float: right;
text-indent: -9999px;
background: url(../img/inline-delete.svg) 0 0 no-repeat;
width: 16px;
height: 16px;
border: 0px none;
}
.inline-deletelink:focus, .inline-deletelink:hover {
cursor: pointer;
}
/* RELATED WIDGET WRAPPER */
.related-widget-wrapper {
display: flex;
gap: 0 10px;
flex-grow: 1;
flex-wrap: wrap;
margin-bottom: 5px;
}
.related-widget-wrapper-link {
opacity: .6;
filter: grayscale(1);
}
.related-widget-wrapper-link:link {
opacity: 1;
filter: grayscale(0);
}
/* GIS MAPS */
.dj_map {
width: 600px;
height: 400px;
}

View file

@ -1,20 +0,0 @@
The MIT License (MIT)
Copyright (c) 2014 Code Charm Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,7 +0,0 @@
All icons are taken from Font Awesome (https://fontawesome.com/) project.
The Font Awesome font is licensed under the SIL OFL 1.1:
- https://scripts.sil.org/OFL
SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG
Font-Awesome-SVG-PNG is licensed under the MIT license (see file license
in current folder).

View file

@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="15"
height="30"
viewBox="0 0 1792 3584"
version="1.1"
id="svg5"
sodipodi:docname="calendar-icons.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview5"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="13.3"
inkscape:cx="15.526316"
inkscape:cy="20.977444"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg5" />
<defs
id="defs2">
<g
id="previous">
<path
d="m 1037,1395 102,-102 q 19,-19 19,-45 0,-26 -19,-45 L 832,896 1139,589 q 19,-19 19,-45 0,-26 -19,-45 L 1037,397 q -19,-19 -45,-19 -26,0 -45,19 L 493,851 q -19,19 -19,45 0,26 19,45 l 454,454 q 19,19 45,19 26,0 45,-19 z m 627,-499 q 0,209 -103,385.5 Q 1458,1458 1281.5,1561 1105,1664 896,1664 687,1664 510.5,1561 334,1458 231,1281.5 128,1105 128,896 128,687 231,510.5 334,334 510.5,231 687,128 896,128 1105,128 1281.5,231 1458,334 1561,510.5 1664,687 1664,896 Z"
id="path1" />
</g>
<g
id="next">
<path
d="m 845,1395 454,-454 q 19,-19 19,-45 0,-26 -19,-45 L 845,397 q -19,-19 -45,-19 -26,0 -45,19 L 653,499 q -19,19 -19,45 0,26 19,45 l 307,307 -307,307 q -19,19 -19,45 0,26 19,45 l 102,102 q 19,19 45,19 26,0 45,-19 z m 819,-499 q 0,209 -103,385.5 Q 1458,1458 1281.5,1561 1105,1664 896,1664 687,1664 510.5,1561 334,1458 231,1281.5 128,1105 128,896 128,687 231,510.5 334,334 510.5,231 687,128 896,128 1105,128 1281.5,231 1458,334 1561,510.5 1664,687 1664,896 Z"
id="path2" />
</g>
</defs>
<use
xlink:href="#next"
x="0"
y="5376"
fill="#000000"
id="use5"
transform="translate(0,-3584)" />
<use
xlink:href="#previous"
x="0"
y="0"
fill="#333333"
id="use2"
style="fill:#000000;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -1 +0,0 @@
<svg width="24" height="22" viewBox="0 0 847 779" xmlns="http://www.w3.org/2000/svg"><g><path fill="#EBECE6" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120z"/><path fill="#9E9E93" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120zm607 25h-607c-26 0-50 11-67 28-17 18-28 41-28 67v536c0 27 11 50 28 68 17 17 41 27 67 27h607c26 0 49-10 67-27 17-18 28-41 28-68v-536c0-26-11-49-28-67-18-17-41-28-67-28z"/><path stroke="#A9A8A4" stroke-width="20" d="M706 295l-68 281"/><path stroke="#E47474" stroke-width="20" d="M316 648l390-353M141 435l175 213"/><path stroke="#C9C9C9" stroke-width="20" d="M319 151l-178 284M706 295l-387-144"/><g fill="#040405"><path d="M319 111c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40zM141 395c22 0 40 18 40 40s-18 40-40 40c-23 0-41-18-41-40s18-40 41-40zM316 608c22 0 40 18 40 40 0 23-18 41-40 41s-40-18-40-41c0-22 18-40 40-40zM706 254c22 0 40 18 40 41 0 22-18 40-40 40s-40-18-40-40c0-23 18-41 40-41zM638 536c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1 +0,0 @@
<svg width="24" height="22" viewBox="0 0 847 779" xmlns="http://www.w3.org/2000/svg"><g><path fill="#F1C02A" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120z"/><path fill="#9E9E93" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120zm607 25h-607c-26 0-50 11-67 28-17 18-28 41-28 67v536c0 27 11 50 28 68 17 17 41 27 67 27h607c26 0 49-10 67-27 17-18 28-41 28-68v-536c0-26-11-49-28-67-18-17-41-28-67-28z"/><path stroke="#A9A8A4" stroke-width="20" d="M706 295l-68 281"/><path stroke="#E47474" stroke-width="20" d="M316 648l390-353M141 435l175 213"/><path stroke="#C9A741" stroke-width="20" d="M319 151l-178 284M706 295l-387-144"/><g fill="#040405"><path d="M319 111c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40zM141 395c22 0 40 18 40 40s-18 40-40 40c-23 0-41-18-41-40s18-40 41-40zM316 608c22 0 40 18 40 40 0 23-18 41-40 41s-40-18-40-41c0-22 18-40 40-40zM706 254c22 0 40 18 40 41 0 22-18 40-40 40s-40-18-40-40c0-23 18-41 40-41zM638 536c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,3 +0,0 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#5fa225" d="M1600 796v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"/>
</svg>

Before

Width:  |  Height:  |  Size: 331 B

View file

@ -1,3 +0,0 @@
<svg width="14" height="14" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#efb80b" d="M1024 1375v-190q0-14-9.5-23.5t-22.5-9.5h-192q-13 0-22.5 9.5t-9.5 23.5v190q0 14 9.5 23.5t22.5 9.5h192q13 0 22.5-9.5t9.5-23.5zm-2-374l18-459q0-12-10-19-13-11-24-11h-220q-11 0-24 11-10 7-10 21l17 457q0 10 10 16.5t24 6.5h185q14 0 23.5-6.5t10.5-16.5zm-14-934l768 1408q35 63-2 126-17 29-46.5 46t-63.5 17h-1536q-34 0-63.5-17t-46.5-46q-37-63-2-126l768-1408q17-31 47-49t65-18 65 18 47 49z"/>
</svg>

Before

Width:  |  Height:  |  Size: 504 B

View file

@ -1,9 +0,0 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="icon">
<path d="M192 1664h288v-288h-288v288zm352 0h320v-288h-320v288zm-352-352h288v-320h-288v320zm352 0h320v-320h-320v320zm-352-384h288v-288h-288v288zm736 736h320v-288h-320v288zm-384-736h320v-288h-320v288zm768 736h288v-288h-288v288zm-384-352h320v-320h-320v320zm-352-864v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm736 864h288v-320h-288v320zm-384-384h320v-288h-320v288zm384 0h288v-288h-288v288zm32-480v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm384-64v1280q0 52-38 90t-90 38h-1408q-52 0-90-38t-38-90v-1280q0-52 38-90t90-38h128v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h384v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h128q52 0 90 38t38 90z"/>
</g>
</defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" />
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,3 +0,0 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#b48c08" d="M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

View file

@ -1,9 +0,0 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="icon">
<path d="M1024 544v448q0 14-9 23t-23 9h-320q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224v-352q0-14 9-23t23-9h64q14 0 23 9t9 23zm416 352q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" />
</svg>

Before

Width:  |  Height:  |  Size: 677 B

View file

@ -1,3 +0,0 @@
<svg width="14" height="14" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#dd4646" d="M1490 1322q0 40-28 68l-136 136q-28 28-68 28t-68-28l-294-294-294 294q-28 28-68 28t-68-28l-136-136q-28-28-28-68t28-68l294-294-294-294q-28-28-28-68t28-68l136-136q28-28 68-28t68 28l294 294 294-294q28-28 68-28t68 28l136 136q28 28 28 68t-28 68l-294 294 294 294q28 28 28 68z"/>
</svg>

Before

Width:  |  Height:  |  Size: 392 B

View file

@ -1,3 +0,0 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#2b70bf" d="m555 1335 78-141q-87-63-136-159t-49-203q0-121 61-225-229 117-381 353 167 258 427 375zm389-759q0-20-14-34t-34-14q-125 0-214.5 89.5T592 832q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm363-191q0 7-1 9-105 188-315 566t-316 567l-49 89q-10 16-28 16-12 0-134-70-16-10-16-28 0-12 44-87-143-65-263.5-173T20 1029Q0 998 0 960t20-69q153-235 380-371t496-136q89 0 180 17l54-97q10-16 28-16 5 0 18 6t31 15.5 33 18.5 31.5 18.5T1291 358q16 10 16 27zm37 447q0 139-79 253.5T1056 1250l280-502q8 45 8 84zm448 128q0 35-20 69-39 64-109 145-150 172-347.5 267T896 1536l74-132q212-18 392.5-137T1664 960q-115-179-282-294l63-112q95 64 182.5 153T1772 891q20 34 20 69z"/>
</svg>

Before

Width:  |  Height:  |  Size: 784 B

View file

@ -1,3 +0,0 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#dd4646" d="M1277 1122q0-26-19-45l-181-181 181-181q19-19 19-45 0-27-19-46l-90-90q-19-19-46-19-26 0-45 19l-181 181-181-181q-19-19-45-19-27 0-46 19l-90 90q-19 19-19 46 0 26 19 45l181 181-181 181q-19 19-19 45 0 27 19 46l90 90q19 19 46 19 26 0 45-19l181-181 181 181q19 19 45 19 27 0 46-19l90-90q19-19 19-46zm387-226q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 560 B

View file

@ -1,3 +0,0 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1024 1376v-192q0-14-9-23t-23-9h-192q-14 0-23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23-9t9-23zm256-672q0-88-55.5-163t-138.5-116-170-41q-243 0-371 213-15 24 8 42l132 100q7 6 19 6 16 0 25-12 53-68 86-92 34-24 86-24 48 0 85.5 26t37.5 59q0 38-20 61t-68 45q-63 28-115.5 86.5t-52.5 125.5v36q0 14 9 23t23 9h192q14 0 23-9t9-23q0-19 21.5-49.5t54.5-49.5q32-18 49-28.5t46-35 44.5-48 28-60.5 12.5-81zm384 192q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 655 B

View file

@ -1,3 +0,0 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#666666" d="M1024 1376v-192q0-14-9-23t-23-9h-192q-14 0-23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23-9t9-23zm256-672q0-88-55.5-163t-138.5-116-170-41q-243 0-371 213-15 24 8 42l132 100q7 6 19 6 16 0 25-12 53-68 86-92 34-24 86-24 48 0 85.5 26t37.5 59q0 38-20 61t-68 45q-63 28-115.5 86.5t-52.5 125.5v36q0 14 9 23t23 9h192q14 0 23-9t9-23q0-19 21.5-49.5t54.5-49.5q32-18 49-28.5t46-35 44.5-48 28-60.5 12.5-81zm384 192q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 655 B

View file

@ -1,3 +0,0 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#2b70bf" d="M1664 960q-152-236-381-353 61 104 61 225 0 185-131.5 316.5t-316.5 131.5-316.5-131.5-131.5-316.5q0-121 61-225-229 117-381 353 133 205 333.5 326.5t434.5 121.5 434.5-121.5 333.5-326.5zm-720-384q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm848 384q0 34-20 69-140 230-376.5 368.5t-499.5 138.5-499.5-139-376.5-368q-20-35-20-69t20-69q140-229 376.5-368t499.5-139 499.5 139 376.5 368q20 35 20 69z"/>
</svg>

Before

Width:  |  Height:  |  Size: 581 B

View file

@ -1,3 +0,0 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#70bf2b" d="M1412 734q0-28-18-46l-91-90q-19-19-45-19t-45 19l-408 407-226-226q-19-19-45-19t-45 19l-91 90q-18 18-18 46 0 27 18 45l362 362q19 19 45 19 27 0 46-19l543-543q18-18 18-45zm252 162q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 436 B

View file

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#999999" d="M1277 1122q0-26-19-45l-181-181 181-181q19-19 19-45 0-27-19-46l-90-90q-19-19-46-19-26 0-45 19l-181 181-181-181q-19-19-45-19-27 0-46 19l-90 90q-19 19-19 46 0 26 19 45l181 181-181 181q-19 19-19 45 0 27 19 46l90 90q19 19 46 19 26 0 45-19l181-181 181 181q19 19 45 19 27 0 46-19l90-90q19-19 19-46zm387-226q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 560 B

View file

@ -1,3 +0,0 @@
<svg width="15" height="15" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#555555" d="M1216 832q0-185-131.5-316.5t-316.5-131.5-316.5 131.5-131.5 316.5 131.5 316.5 316.5 131.5 316.5-131.5 131.5-316.5zm512 832q0 52-38 90t-90 38q-54 0-90-38l-343-342q-179 124-399 124-143 0-273.5-55.5t-225-150-150-225-55.5-273.5 55.5-273.5 150-225 225-150 273.5-55.5 273.5 55.5 225 150 150 225 55.5 273.5q0 220-124 399l343 343q37 37 37 90z"/>
</svg>

Before

Width:  |  Height:  |  Size: 458 B

View file

@ -1,34 +0,0 @@
<svg width="16" height="192" viewBox="0 0 1792 21504" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="up">
<path d="M1412 895q0-27-18-45l-362-362-91-91q-18-18-45-18t-45 18l-91 91-362 362q-18 18-18 45t18 45l91 91q18 18 45 18t45-18l189-189v502q0 26 19 45t45 19h128q26 0 45-19t19-45v-502l189 189q19 19 45 19t45-19l91-91q18-18 18-45zm252 1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="down">
<path d="M1412 897q0-27-18-45l-91-91q-18-18-45-18t-45 18l-189 189v-502q0-26-19-45t-45-19h-128q-26 0-45 19t-19 45v502l-189-189q-19-19-45-19t-45 19l-91 91q-18 18-18 45t18 45l362 362 91 91q18 18 45 18t45-18l91-91 362-362q18-18 18-45zm252-1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="left">
<path d="M1408 960v-128q0-26-19-45t-45-19h-502l189-189q19-19 19-45t-19-45l-91-91q-18-18-45-18t-45 18l-362 362-91 91q-18 18-18 45t18 45l91 91 362 362q18 18 45 18t45-18l91-91q18-18 18-45t-18-45l-189-189h502q26 0 45-19t19-45zm256-64q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="right">
<path d="M1413 896q0-27-18-45l-91-91-362-362q-18-18-45-18t-45 18l-91 91q-18 18-18 45t18 45l189 189h-502q-26 0-45 19t-19 45v128q0 26 19 45t45 19h502l-189 189q-19 19-19 45t19 45l91 91q18 18 45 18t45-18l362-362 91-91q18-18 18-45zm251 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="clearall">
<path transform="translate(336, 336) scale(0.75)" d="M1037 1395l102-102q19-19 19-45t-19-45l-307-307 307-307q19-19 19-45t-19-45l-102-102q-19-19-45-19t-45 19l-454 454q-19 19-19 45t19 45l454 454q19 19 45 19t45-19zm627-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="chooseall">
<path transform="translate(336, 336) scale(0.75)" d="M845 1395l454-454q19-19 19-45t-19-45l-454-454q-19-19-45-19t-45 19l-102 102q-19 19-19 45t19 45l307 307-307 307q-19 19-19 45t19 45l102 102q19 19 45 19t45-19zm819-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#up" x="0" y="0" fill="#666666" />
<use xlink:href="#up" x="0" y="1792" fill="#447e9b" />
<use xlink:href="#down" x="0" y="3584" fill="#666666" />
<use xlink:href="#down" x="0" y="5376" fill="#447e9b" />
<use xlink:href="#left" x="0" y="7168" fill="#666666" />
<use xlink:href="#left" x="0" y="8960" fill="#447e9b" />
<use xlink:href="#right" x="0" y="10752" fill="#666666" />
<use xlink:href="#right" x="0" y="12544" fill="#447e9b" />
<use xlink:href="#clearall" x="0" y="14336" fill="#666666" />
<use xlink:href="#clearall" x="0" y="16128" fill="#447e9b" />
<use xlink:href="#chooseall" x="0" y="17920" fill="#666666" />
<use xlink:href="#chooseall" x="0" y="19712" fill="#447e9b" />
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

Some files were not shown because too many files have changed in this diff Show more