usermanagement/translation/calendar

This commit is contained in:
jschaufuss@leitwerk.de
2025-08-11 12:27:30 +02:00
parent 13a5286357
commit c2bb64d961
27 changed files with 998 additions and 240 deletions

View File

@@ -10,15 +10,15 @@ class CustomUserCreationForm(UserCreationForm):
fields = ('username', 'email', 'password1', 'password2') fields = ('username', 'email', 'password1', 'password2')
class CustomUserChangeForm(UserChangeForm): class CustomUserChangeForm(UserChangeForm):
password = None # Passwort-Änderung über extra Formular password = None # Password change via separate form
class Meta: class Meta:
model = User model = User
fields = ('email',) fields = ('email',)
widgets = { widgets = {
'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'E-Mail-Adresse'}), 'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'Email address'}),
} }
class JellyfinLoginForm(forms.Form): class JellyfinLoginForm(forms.Form):
username = forms.CharField(label='Benutzername', widget=forms.TextInput(attrs={'class': 'form-control'})) username = forms.CharField(label='Username', widget=forms.TextInput(attrs={'class': 'form-control'}))
password = forms.CharField(label='Passwort', widget=forms.PasswordInput(attrs={'class': 'form-control'})) password = forms.CharField(label='Password', widget=forms.PasswordInput(attrs={'class': 'form-control'}))

View File

@@ -4,9 +4,9 @@ from django.utils.translation import gettext_lazy as _
class User(AbstractUser): class User(AbstractUser):
""" """
Custom User Model mit zusätzlichen Feldern und Berechtigungen. Custom User Model with additional fields and permissions.
Normale User können nur ihre eigenen Daten bearbeiten. Regular users can only edit their own data.
Admin-User können alles. Admin users can edit everything.
""" """
email = models.EmailField(_("email address"), unique=True) email = models.EmailField(_("email address"), unique=True)
bio = models.TextField(max_length=500, blank=True) bio = models.TextField(max_length=500, blank=True)
@@ -26,7 +26,7 @@ class User(AbstractUser):
client = JellyfinClient() client = JellyfinClient()
return client.is_admin(self.jellyfin_user_id, self.jellyfin_token) return client.is_admin(self.jellyfin_user_id, self.jellyfin_token)
except: except:
# Im Fehlerfall den lokalen Status verwenden # On error, fall back to local status
return self.is_admin return self.is_admin
@property @property

View File

@@ -2,14 +2,14 @@
{% block content %} {% block content %}
<div class="auth-container"> <div class="auth-container">
<h2>Anmelden</h2> <h2>Sign in</h2>
<form method="post" class="auth-form"> <form method="post" class="auth-form">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="btn-primary">Anmelden</button> <button type="submit" class="btn-primary">Sign in</button>
</form> </form>
<div class="auth-links"> <div class="auth-links">
<p>Noch kein Konto? <a href="{% url 'accounts:register' %}">Jetzt registrieren</a></p> <p>Don't have an account? <a href="{% url 'accounts:register' %}">Register now</a></p>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -2,11 +2,11 @@
{% block content %} {% block content %}
<div class="auth-container"> <div class="auth-container">
<h2>Passwort ändern</h2> <h2>Change password</h2>
<form method="post" class="auth-form"> <form method="post" class="auth-form">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="btn-primary">Passwort ändern</button> <button type="submit" class="btn-primary">Change password</button>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -2,8 +2,8 @@
{% block content %} {% block content %}
<div class="auth-container"> <div class="auth-container">
<h2>Passwort geändert</h2> <h2>Password changed</h2>
<p>Ihr Passwort wurde erfolgreich geändert.</p> <p>Your password has been changed successfully.</p>
<p><a href="{% url 'accounts:profile' %}">Zurück zum Profil</a></p> <p><a href="{% url 'accounts:profile' %}">Back to profile</a></p>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -7,7 +7,7 @@
{% block content %} {% block content %}
<div class="profile-container"> <div class="profile-container">
<h2>Hallo, {{ user.username }}</h2> <h2>Hello, {{ user.username }}</h2>
{% if messages %} {% if messages %}
<div class="messages"> <div class="messages">
@@ -18,24 +18,24 @@
{% endif %} {% endif %}
<div class="profile-section"> <div class="profile-section">
<h3>E-Mail-Adresse</h3> <h3>Email address</h3>
<form method="post" class="profile-form compact-form"> <form method="post" class="profile-form compact-form">
{% csrf_token %} {% csrf_token %}
<div class="form-row"> <div class="form-row">
<label for="id_email">E-Mail</label> <label for="id_email">Email</label>
{{ form.email }} {{ form.email }}
</div> </div>
<button type="submit" class="btn-primary">Speichern</button> <button type="submit" class="btn-primary">Save</button>
</form> </form>
{% if user.jellyfin_server %} {% if user.jellyfin_server %}
<div class="jellyfin-info"> <div class="jellyfin-info">
<h4>Jellyfin-Verbindung</h4> <h4>Jellyfin connection</h4>
<p> <p>
Server: {{ user.jellyfin_server }}<br> Server: {{ user.jellyfin_server }}<br>
Status: {% if user.jellyfin_token %}Verbunden{% else %}Nicht verbunden{% endif %}<br> Status: {% if user.jellyfin_token %}Connected{% else %}Not connected{% endif %}<br>
{% if user.is_jellyfin_admin %} {% if user.is_jellyfin_admin %}
<span class="badge badge-admin">Jellyfin Administrator</span> <span class="badge badge-admin">Jellyfin administrator</span>
{% endif %} {% endif %}
</p> </p>
</div> </div>
@@ -43,9 +43,9 @@
</div> </div>
<div class="profile-section"> <div class="profile-section">
<h3>Meine Abonnements</h3> <h3>My subscriptions</h3>
<h4>Serien</h4> <h4>Series</h4>
{% if series_subs %} {% if series_subs %}
<div class="subscription-list"> <div class="subscription-list">
{% for sub in series_subs %} {% for sub in series_subs %}
@@ -53,11 +53,11 @@
{% if sub.series_poster %} {% if sub.series_poster %}
<img src="{{ sub.series_poster }}" alt="{{ sub.series_title }}" class="subscription-poster"> <img src="{{ sub.series_poster }}" alt="{{ sub.series_title }}" class="subscription-poster">
{% else %} {% else %}
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster"> <img src="https://via.placeholder.com/80x120?text=No+Poster" alt="" class="subscription-poster">
{% endif %} {% endif %}
<div class="subscription-info"> <div class="subscription-info">
<div class="subscription-title">{{ sub.series_title }}</div> <div class="subscription-title">{{ sub.series_title }}</div>
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div> <div class="subscription-date">Subscribed on {{ sub.created_at|date:"d.m.Y" }}</div>
{% if sub.series_overview %} {% if sub.series_overview %}
<div class="subscription-overview">{{ sub.series_overview|truncatechars:100 }}</div> <div class="subscription-overview">{{ sub.series_overview|truncatechars:100 }}</div>
{% endif %} {% endif %}
@@ -66,10 +66,10 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="muted">Keine Serien abonniert.</p> <p class="muted">No series subscribed.</p>
{% endif %} {% endif %}
<h4>Filme</h4> <h4>Movies</h4>
{% if movie_subs %} {% if movie_subs %}
<div class="subscription-list"> <div class="subscription-list">
{% for sub in movie_subs %} {% for sub in movie_subs %}
@@ -77,11 +77,11 @@
{% if sub.poster %} {% if sub.poster %}
<img src="{{ sub.poster }}" alt="{{ sub.title }}" class="subscription-poster"> <img src="{{ sub.poster }}" alt="{{ sub.title }}" class="subscription-poster">
{% else %} {% else %}
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster"> <img src="https://via.placeholder.com/80x120?text=No+Poster" alt="" class="subscription-poster">
{% endif %} {% endif %}
<div class="subscription-info"> <div class="subscription-info">
<div class="subscription-title">{{ sub.title }}</div> <div class="subscription-title">{{ sub.title }}</div>
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div> <div class="subscription-date">Subscribed on {{ sub.created_at|date:"d.m.Y" }}</div>
{% if sub.overview %} {% if sub.overview %}
<div class="subscription-overview">{{ sub.overview|truncatechars:100 }}</div> <div class="subscription-overview">{{ sub.overview|truncatechars:100 }}</div>
{% endif %} {% endif %}
@@ -90,7 +90,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="muted">Keine Filme abonniert.</p> <p class="muted">No movies subscribed.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -2,14 +2,14 @@
{% block content %} {% block content %}
<div class="auth-container"> <div class="auth-container">
<h2>Registrieren</h2> <h2>Register</h2>
<form method="post" class="auth-form"> <form method="post" class="auth-form">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="btn-primary">Registrieren</button> <button type="submit" class="btn-primary">Register</button>
</form> </form>
<div class="auth-links"> <div class="auth-links">
<p>Bereits ein Konto? <a href="{% url 'accounts:login' %}">Jetzt anmelden</a></p> <p>Already have an account? <a href="{% url 'accounts:login' %}">Sign in</a></p>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -7,7 +7,7 @@ from django.contrib import messages
class JellyfinClient: class JellyfinClient:
def __init__(self): def __init__(self):
# Basis-Einstellungen aus den Django-Settings # Base settings from Django settings
self.client = settings.JELLYFIN_CLIENT self.client = settings.JELLYFIN_CLIENT
self.version = settings.JELLYFIN_VERSION self.version = settings.JELLYFIN_VERSION
self.device = settings.JELLYFIN_DEVICE self.device = settings.JELLYFIN_DEVICE
@@ -18,13 +18,13 @@ class JellyfinClient:
def authenticate(self, username, password): def authenticate(self, username, password):
"""Authenticate with Jellyfin and return user info if successful""" """Authenticate with Jellyfin and return user info if successful"""
if not self.server_url: if not self.server_url:
raise ValueError("Keine Server-URL angegeben") raise ValueError("No server URL provided")
# Stelle sicher, dass die URL ein Protokoll hat # Ensure the URL has a protocol
if not self.server_url.startswith(('http://', 'https://')): if not self.server_url.startswith(('http://', 'https://')):
self.server_url = f'http://{self.server_url}' self.server_url = f'http://{self.server_url}'
# Entferne trailing slashes # Remove trailing slashes
self.server_url = self.server_url.rstrip('/') self.server_url = self.server_url.rstrip('/')
headers = { headers = {
@@ -57,13 +57,13 @@ class JellyfinClient:
'is_admin': data['User'].get('Policy', {}).get('IsAdministrator', False) 'is_admin': data['User'].get('Policy', {}).get('IsAdministrator', False)
} }
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
raise ValueError("Verbindung zum Server nicht möglich. Bitte überprüfen Sie die Server-URL.") raise ValueError("Unable to connect to the server. Please check the server URL.")
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
raise ValueError("Zeitüberschreitung bei der Verbindung zum Server.") raise ValueError("Connection to the server timed out.")
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
if e.response.status_code == 401: if e.response.status_code == 401:
return None # Authentifizierung fehlgeschlagen return None # Authentifizierung fehlgeschlagen
raise ValueError(f"HTTP-Fehler: {e.response.status_code}") raise ValueError(f"HTTP error: {e.response.status_code}")
except Exception as e: except Exception as e:
return None return None
@@ -71,7 +71,7 @@ class JellyfinClient:
"""Check if user is admin in Jellyfin""" """Check if user is admin in Jellyfin"""
cache_key = f'jellyfin_admin_{user_id}' cache_key = f'jellyfin_admin_{user_id}'
# Check cache first # Check cache first
cached = cache.get(cache_key) cached = cache.get(cache_key)
if cached is not None: if cached is not None:
return cached return cached
@@ -106,11 +106,11 @@ def jellyfin_admin_required(view_func):
@wraps(view_func) @wraps(view_func)
def _wrapped_view(request, *args, **kwargs): def _wrapped_view(request, *args, **kwargs):
if not request.user.is_authenticated: if not request.user.is_authenticated:
messages.error(request, 'Sie müssen angemeldet sein, um diese Seite zu sehen.') messages.error(request, 'You must be logged in to view this page.')
return redirect('accounts:login') return redirect('accounts:login')
if not request.user.is_jellyfin_admin: if not request.user.is_jellyfin_admin:
messages.error(request, 'Sie benötigen Admin-Rechte, um diese Seite zu sehen.') messages.error(request, 'You need admin rights to view this page.')
return redirect('index') return redirect('index')
return view_func(request, *args, **kwargs) return view_func(request, *args, **kwargs)

View File

@@ -17,7 +17,7 @@ class RegisterView(CreateView):
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) response = super().form_valid(form)
messages.success(self.request, 'Registrierung erfolgreich! Sie können sich jetzt anmelden.') messages.success(self.request, 'Registration successful! You can now sign in.')
return response return response
@login_required @login_required
@@ -26,12 +26,12 @@ def profile(request):
form = CustomUserChangeForm(request.POST, instance=request.user) form = CustomUserChangeForm(request.POST, instance=request.user)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(request, 'E-Mail gespeichert.') messages.success(request, 'Email saved.')
return redirect('accounts:profile') return redirect('accounts:profile')
else: else:
form = CustomUserChangeForm(instance=request.user) form = CustomUserChangeForm(instance=request.user)
# Lade Abonnements # Load subscriptions
series_subs = request.user.series_subscriptions.all() series_subs = request.user.series_subscriptions.all()
movie_subs = request.user.movie_subscriptions.all() movie_subs = request.user.movie_subscriptions.all()
@@ -40,7 +40,7 @@ def profile(request):
from settingspanel.models import AppSettings from settingspanel.models import AppSettings
from arr_api.services import sonarr_get_series, radarr_lookup_movie_by_title from arr_api.services import sonarr_get_series, radarr_lookup_movie_by_title
cfg = AppSettings.current() cfg = AppSettings.current()
# Serien # Series
for sub in series_subs: for sub in series_subs:
if not sub.series_poster and sub.series_id: if not sub.series_poster and sub.series_id:
details = sonarr_get_series(sub.series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key) details = sonarr_get_series(sub.series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key)
@@ -51,7 +51,7 @@ def profile(request):
if not sub.series_genres: if not sub.series_genres:
sub.series_genres = details.get('series_genres') or [] sub.series_genres = details.get('series_genres') or []
sub.save(update_fields=['series_poster', 'series_overview', 'series_genres']) sub.save(update_fields=['series_poster', 'series_overview', 'series_genres'])
# Filme # Movies
for sub in movie_subs: for sub in movie_subs:
if not sub.poster: if not sub.poster:
details = radarr_lookup_movie_by_title(sub.title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key) details = radarr_lookup_movie_by_title(sub.title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key)
@@ -84,7 +84,7 @@ def jellyfin_login(request):
app_settings = AppSettings.current() app_settings = AppSettings.current()
server_url = app_settings.get_jellyfin_url() server_url = app_settings.get_jellyfin_url()
if not server_url: if not server_url:
messages.error(request, 'Jellyfin Server ist nicht konfiguriert. Bitte Setup abschließen.') messages.error(request, 'Jellyfin server is not configured. Please complete setup.')
return render(request, 'accounts/login.html', {'form': form}) return render(request, 'accounts/login.html', {'form': form})
try: try:
@@ -93,7 +93,7 @@ def jellyfin_login(request):
auth_result = client.authenticate(username, password) auth_result = client.authenticate(username, password)
if not auth_result: if not auth_result:
messages.error(request, 'Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.') messages.error(request, 'Sign in failed. Please check your credentials.')
return render(request, 'accounts/login.html', {'form': form}) return render(request, 'accounts/login.html', {'form': form})
# Existierenden User finden oder neu erstellen # Existierenden User finden oder neu erstellen
@@ -116,13 +116,13 @@ def jellyfin_login(request):
user.save() user.save()
login(request, user) login(request, user)
messages.success(request, f'Willkommen, {username}!') messages.success(request, f'Welcome, {username}!')
return redirect('arr_api:index') return redirect('arr_api:index')
except ValueError as e: except ValueError as e:
messages.error(request, str(e)) messages.error(request, str(e))
except Exception as e: except Exception as e:
messages.error(request, f'Verbindungsfehler: {str(e)}') messages.error(request, f'Connection error: {str(e)}')
# invalid form or error path # invalid form or error path
return render(request, 'accounts/login.html', {'form': form}) return render(request, 'accounts/login.html', {'form': form})

View File

@@ -3,12 +3,12 @@ from django.utils import timezone
from arr_api.notifications import check_and_notify_users from arr_api.notifications import check_and_notify_users
class Command(BaseCommand): class Command(BaseCommand):
help = 'Prüft neue Medien und sendet Benachrichtigungen' help = 'Checks for new media and sends notifications'
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
self.stdout.write(f'[{timezone.now()}] Starte Medien-Check...') self.stdout.write(f'[{timezone.now()}] Starting media check...')
try: try:
check_and_notify_users() check_and_notify_users()
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Medien-Check erfolgreich beendet')) self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Media check finished successfully'))
except Exception as e: except Exception as e:
self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Fehler beim Medien-Check: {str(e)}')) self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Error during media check: {str(e)}'))

View File

@@ -12,7 +12,7 @@ class SeriesSubscription(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
unique_together = ['user', 'series_id'] # Ein User kann eine Serie nur einmal abonnieren unique_together = ['user', 'series_id'] # A user can subscribe to a series only once
def __str__(self): def __str__(self):
return self.series_title return self.series_title
@@ -29,18 +29,16 @@ class MovieSubscription(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
unique_together = ['user', 'movie_id'] # Ein User kann einen Film nur einmal abonnieren unique_together = ['user', 'movie_id'] # A user can subscribe to a movie only once
def __str__(self): def __str__(self):
return self.title return self.title
class SentNotification(models.Model): class SentNotification(models.Model):
""" """Store sent notifications to avoid duplicates"""
Speichert gesendete Benachrichtigungen um Duplikate zu vermeiden
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
media_id = models.IntegerField() media_id = models.IntegerField()
media_type = models.CharField(max_length=10) # 'series' oder 'movie' media_type = models.CharField(max_length=10) # 'series' or 'movie'
media_title = models.CharField(max_length=255) media_title = models.CharField(max_length=255)
air_date = models.DateField() air_date = models.DateField()
sent_at = models.DateTimeField(auto_now_add=True) sent_at = models.DateTimeField(auto_now_add=True)

View File

@@ -68,7 +68,7 @@ def send_notification_email(
release_type=None, release_type=None,
): ):
""" """
Sendet eine Benachrichtigungs-E-Mail an einen User mit erweiterten Details Sends a notification email to a user with extended details
""" """
eff = _set_runtime_email_settings() eff = _set_runtime_email_settings()
logger.info( logger.info(
@@ -94,7 +94,7 @@ def send_notification_email(
context = { context = {
'username': user.username, 'username': user.username,
'title': media_title, 'title': media_title,
'type': 'Serie' if media_type == 'series' else 'Film', 'type': 'Series' if media_type == 'series' else 'Movie',
'overview': overview, 'overview': overview,
'poster_url': poster_url, 'poster_url': poster_url,
'episode_title': episode_title, 'episode_title': episode_title,
@@ -105,7 +105,7 @@ def send_notification_email(
'release_type': release_type, 'release_type': release_type,
} }
subject = f"Neue {context['type']} verfügbar: {media_title}" subject = f"New {context['type']} available: {media_title}"
message = render_to_string('arr_api/email/new_media_notification.html', context) message = render_to_string('arr_api/email/new_media_notification.html', context)
send_mail( send_mail(
@@ -209,8 +209,8 @@ def get_todays_radarr_calendar():
def check_jellyfin_availability(user, media_id, media_type): def check_jellyfin_availability(user, media_id, media_type):
""" """
Ersetzt: Wir prüfen Verfügbarkeit über Sonarr/Radarr (hasFile), Replaced: We check availability via Sonarr/Radarr (hasFile),
was zuverlässig ist, wenn Jellyfin dieselben Ordner scannt. which is reliable if Jellyfin scans the same folders.
""" """
# user is unused here; kept for backward compatibility # user is unused here; kept for backward compatibility
if media_type == 'series': if media_type == 'series':

View File

@@ -0,0 +1,301 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Calendar Subscribarr{% endblock %}
{% block extra_style %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css" rel="stylesheet">
<style>
:root {
--primary: #3fb950; /* Subscribarr green */
--bg-soft: #0f172a;
--card: #0b1224;
--text: #e6edf3;
--muted: #94a3b8;
--border: #1f2a44;
}
.wrap { max-width: 1200px; margin: 0 auto; padding: 12px; }
h1 { margin: 10px 0 18px; font-size: 22px; }
/* FullCalendar theme tweaks */
.fc { --fc-border-color: var(--border); color: var(--text); }
.fc-theme-standard { --fc-page-bg-color: var(--card); --fc-neutral-bg-color: #0f172a; }
.fc .fc-scrollgrid, .fc .fc-scrollgrid-section > td { background: var(--card); }
.fc .fc-col-header, .fc .fc-col-header-cell { background: #0f172a; }
.fc .fc-daygrid-day, .fc .fc-timegrid-slot { background: transparent; }
.fc .fc-toolbar { gap: 8px; margin-bottom: 12px; display: flex; flex-wrap: wrap; }
.fc .fc-toolbar-title { font-size: 18px; }
.fc .fc-button { background: #121b33; border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 8px; }
.fc .fc-button-primary:not(:disabled).fc-button-active,
.fc .fc-button-primary:not(:disabled):active { background: #1a2542; border-color: var(--border); }
.fc .fc-button:hover { filter: brightness(1.1); }
.fc .fc-button:focus { box-shadow: 0 0 0 2px rgba(63,185,80,.35); }
.fc .fc-daygrid-day-number { color: var(--muted); }
.fc .fc-day-today { background: rgba(63,185,80,0.08); }
.fc .fc-col-header-cell-cushion { color: var(--muted); }
.fc .fc-daygrid-event, .fc .fc-timegrid-event, .fc .fc-list-event { cursor: pointer; }
.fc .fc-daygrid-event { border-radius: 6px; padding: 2px 4px; border: 1px solid var(--border); }
.fc .subscribed-event { border-left: 4px solid var(--primary) !important; background: rgba(63,185,80,0.06); }
.fc .event-series { border-left-color: #60a5fa; }
.fc .event-movie { border-left-color: #f59e0b; }
.fc .fc-list {
border: 1px solid var(--border);
border-radius: 10px; overflow: hidden;
}
.fc .fc-list-event:hover { background: rgba(255,255,255,0.03); }
.event-poster { width: 24px; height: 36px; object-fit: cover; border-radius: 2px; margin-right: 6px; }
#calendar { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 100%; }
/* Compact tweaks for phones */
@media (max-width: 640px) {
.fc .fc-toolbar-title { font-size: 16px; }
.fc .fc-button { padding: 5px 8px; border-radius: 8px; }
.fc .fc-col-header-cell-cushion { font-size: 12px; }
.fc .fc-daygrid-day-number { font-size: 12px; }
.fc .fc-daygrid-event { font-size: 12px; padding: 1px 3px; }
.event-poster { width: 20px; height: 30px; }
}
/* Modal minor polish in this page */
.modal { background: #0b1224; border: 1px solid var(--border); }
.btn-subscribe { background: var(--primary); color: #06210e; }
</style>
<link rel="stylesheet" href="{% static 'css/index.css' %}">
{% endblock %}
{% block content %}
<div class="wrap">
<h1>Calendar</h1>
<div id="calendar"></div>
</div>
<!-- Modal (same as on the homepage) -->
<div id="modalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true" style="display:none">
<div class="modal">
<div class="modal-header">
<div class="m-poster-wrap">
<div class="m-poster"><img id="mPoster" alt=""></div>
<button id="subscribeBtn" class="btn-subscribe" type="button">Subscribe</button>
</div>
<div>
<div id="mTitle" class="m-title"></div>
<div id="mSub" class="m-sub"></div>
<div id="mBadges" class="badges"></div>
</div>
<button class="modal-close" title="Close" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="section-block">
<div class="section-title">Overview</div>
<div id="mOverview" class="desc muted"></div>
</div>
<div class="section-block">
<div class="section-title">Upcoming episodes</div>
<div class="section-divider"></div>
<div id="mEpisodes"></div>
</div>
</div>
</div>
</div>
{% csrf_token %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script>
<script>
(function(){
const $ = (s, r=document) => r.querySelector(s);
const backdrop = $("#modalBackdrop");
const closeBtn = backdrop.querySelector(".modal-close");
const mPoster = $("#mPoster");
const mTitle = $("#mTitle");
const mOverview = $("#mOverview");
const mEpisodes = $("#mEpisodes");
const mSub = $("#mSub");
const subscribeBtn = $("#subscribeBtn");
const epSection = mEpisodes.closest(".section-block");
const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value;
const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('subscribarr-sub') : null;
function openModal(){ backdrop.style.display = 'flex'; backdrop.setAttribute('aria-hidden','false'); document.body.style.overflow='hidden'; }
function closeModal(){ backdrop.style.display = 'none'; backdrop.setAttribute('aria-hidden','true'); document.body.style.overflow=''; }
closeBtn.addEventListener('click', closeModal);
backdrop.addEventListener('click', (e)=>{ if(e.target===backdrop) closeModal(); });
window.addEventListener('keydown', e=>{ if(e.key==='Escape') closeModal(); });
// subscription cache
const subCache = new Map();
async function loadAllSubs(){
try {
const [s,m] = await Promise.all([
fetch('/api/series/subscriptions/'),
fetch('/api/movies/subscriptions/')
]);
if(s.ok){ (await s.json()).forEach(id => subCache.set(`series:${id}`, true)); }
if(m.ok){ (await m.json()).forEach(title => subCache.set(`movie:${title}`, true)); }
} catch(err){ console.warn('subs load failed', err); }
}
function isSub(kind, idOrTitle){
const key = kind==='series' ? `series:${idOrTitle}` : `movie:${idOrTitle}`;
return subCache.has(key);
}
async function toggleSub(kind, idOrTitle, on){
const url = kind==='series'
? `/api/series/${on?'subscribe':'unsubscribe'}/${encodeURIComponent(idOrTitle)}/`
: `/api/movies/${on?'subscribe':'unsubscribe'}/${encodeURIComponent(idOrTitle)}/`;
const resp = await fetch(url, {method:'POST', headers:{'X-CSRFToken': csrf}});
if(!resp.ok){ throw new Error('HTTP '+resp.status); }
const key = kind==='series' ? `series:${idOrTitle}` : `movie:${idOrTitle}`;
if(on) subCache.set(key, true); else subCache.delete(key);
}
let currentEvent = null;
let calendar = null;
function showEvent(ev){
currentEvent = ev;
const p = ev.extendedProps || {};
const kind = p.kind;
const poster = p.poster || 'https://via.placeholder.com/130x195?text=No+Poster';
mPoster.src = poster;
if(kind==='series'){
mTitle.textContent = `${p.seriesTitle} — S${p.seasonNumber}E${p.episodeNumber}${p.episodeTitle?(' · '+p.episodeTitle):''}`;
mOverview.textContent = p.overview || '';
epSection.style.display = 'none';
mEpisodes.innerHTML = '';
mSub.textContent = new Date(ev.start).toLocaleString();
} else {
mTitle.textContent = p.title || ev.title;
mOverview.textContent = p.overview || '';
epSection.style.display = 'none';
mEpisodes.innerHTML = '';
mSub.textContent = new Date(ev.start).toLocaleDateString();
}
subscribeBtn.textContent = isSub(kind, kind==='series'?p.seriesId:(p.title||'')) ? 'Unsubscribe' : 'Subscribe';
openModal();
}
subscribeBtn.addEventListener('click', async ()=>{
if(!currentEvent) return;
const p = currentEvent.extendedProps || {};
const kind = p.kind;
const key = kind==='series'? p.seriesId : (p.title||'');
const newState = !isSub(kind, key);
// Optimistic UI
subscribeBtn.textContent = newState ? 'Unsubscribe' : 'Subscribe';
// Update event visuals immediately
try {
if(currentEvent){
currentEvent.setExtendedProp('subscribed', newState);
// Also tag by kind for subtle color; ensure classNames contains baseline kind
const base = [];
if((currentEvent.extendedProps||{}).kind === 'series') base.push('event-series');
if((currentEvent.extendedProps||{}).kind === 'movie') base.push('event-movie');
if(newState) base.push('subscribed-event');
currentEvent.setProp('classNames', base);
if(calendar) calendar.rerenderEvents();
}
} catch(e) { /* ignore */ }
try {
await toggleSub(kind, key, newState);
if(bc) bc.postMessage({ type:'sub_change', kind, key, on:newState });
} catch(err) {
console.error(err);
// revert visual/state on failure
try {
if(currentEvent){
currentEvent.setExtendedProp('subscribed', !newState);
const base = [];
if((currentEvent.extendedProps||{}).kind === 'series') base.push('event-series');
if((currentEvent.extendedProps||{}).kind === 'movie') base.push('event-movie');
if(!newState) base.push('subscribed-event');
currentEvent.setProp('classNames', base);
if(calendar) calendar.rerenderEvents();
}
} catch(e) { /* ignore */ }
}
});
document.addEventListener('DOMContentLoaded', async function() {
await loadAllSubs();
const calendarEl = document.getElementById('calendar');
const isCompact = () => (calendarEl?.clientWidth || window.innerWidth) < 720;
const compact = isCompact();
calendar = new FullCalendar.Calendar(calendarEl, {
initialView: compact ? 'listWeek' : 'dayGridMonth',
height: 'auto',
locale: 'en',
headerToolbar: compact
? { left:'prev,next', center:'title', right:'listWeek,dayGridMonth' }
: { left:'prev,next today', center:'title', right:'dayGridMonth,timeGridWeek,listWeek' },
buttonText: { listWeek: 'List', dayGridMonth: 'Month', timeGridWeek: 'Week', today: 'Today' },
firstDay: 1,
nowIndicator: true,
dayMaxEventRows: compact ? 2 : 5,
expandRows: true,
handleWindowResize: true,
windowResize: function(){
const c = isCompact();
const should = c ? 'listWeek' : 'dayGridMonth';
if(calendar.view.type !== should){ calendar.changeView(should); }
calendar.setOption('headerToolbar', c
? { left:'prev,next', center:'title', right:'listWeek,dayGridMonth' }
: { left:'prev,next today', center:'title', right:'dayGridMonth,timeGridWeek,listWeek' }
);
calendar.setOption('dayMaxEventRows', c ? 2 : 5);
calendar.setOption('displayEventTime', !c);
},
eventClassNames: function(arg){
const p = arg.event.extendedProps || {};
const classes = [];
if(p.kind === 'series') classes.push('event-series');
if(p.kind === 'movie') classes.push('event-movie');
if(p.subscribed) classes.push('subscribed-event');
return classes;
},
events: async (info, success, failure) => {
try {
const url = `/api/calendar/events/?days={{ days|default:60 }}`;
const resp = await fetch(url);
const data = await resp.json();
const evs = (data.events||[]);
success(evs);
} catch(err){ failure(err); }
},
eventClick: function(arg){ arg.jsEvent.preventDefault(); showEvent(arg.event); },
eventDidMount: function(info){
const p = info.event.extendedProps || {};
if(info.view.type.startsWith('list') && p.poster){
const img = document.createElement('img');
img.src = p.poster; img.className = 'event-poster';
const titleEl = info.el.querySelector('.fc-list-event-title');
if(titleEl){ titleEl.prepend(img); }
}
}
});
calendar.render();
// Listen for sub changes from other tabs/pages
if(bc){
bc.onmessage = (evt)=>{
const msg = evt.data || {};
if(msg.type !== 'sub_change') return;
const {kind, key, on} = msg;
try {
calendar.getEvents().forEach(ev=>{
const p = ev.extendedProps || {};
const match = (kind==='series' && String(p.seriesId)===String(key)) || (kind==='movie' && (p.title||'')===key);
if(match){
ev.setExtendedProp('subscribed', on);
const base = [];
if(p.kind==='series') base.push('event-series');
if(p.kind==='movie') base.push('event-movie');
if(on) base.push('subscribed-event');
ev.setProp('classNames', base);
}
});
calendar.rerenderEvents();
} catch(e) { /* ignore */ }
};
}
});
})();
</script>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% block title %}Subscribarr Übersicht{% endblock %} {% block title %}Subscribarr Overview{% endblock %}
{% block extra_style %} {% block extra_style %}
<link rel="stylesheet" href="{% static 'css/index.css' %}"> <link rel="stylesheet" href="{% static 'css/index.css' %}">
@@ -20,26 +20,30 @@
<div class="controls"> <div class="controls">
<form method="get" class="controls-form"> <form method="get" class="controls-form">
<input type="hidden" name="kind" value="{{ kind|default:'all' }}"> <input type="hidden" name="kind" value="{{ kind|default:'all' }}">
<input type="text" name="q" placeholder="Suche Serien/Filme…" value="{{ query|default:'' }}"> <input type="text" name="q" placeholder="Search series/movies…" value="{{ query|default:'' }}">
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Zeitraum in Tagen"> <input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Time range (days)">
<button type="submit">Suchen</button> <button type="submit">Search</button>
</form> </form>
<nav class="seg" aria-label="Typ filtern"> <nav class="seg" aria-label="Filter type">
{% with qs=query|urlencode %} {% with qs=query|urlencode %}
<a href="?kind=all&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}" <a href="?kind=all&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
class="{% if kind == 'all' %}active{% endif %}">Alle</a> class="{% if kind == 'all' %}active{% endif %}">All</a>
<a href="?kind=series&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}" <a href="?kind=series&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
class="{% if kind == 'series' %}active{% endif %}">Serien</a> class="{% if kind == 'series' %}active{% endif %}">Series</a>
<a href="?kind=movies&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}" <a href="?kind=movies&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
class="{% if kind == 'movies' %}active{% endif %}">Filme</a> class="{% if kind == 'movies' %}active{% endif %}">Movies</a>
{% endwith %} {% endwith %}
</nav> </nav>
<div class="controls-actions">
<a href="/calendar/" class="btn btn-accent" title="Open calendar">📅 Calendar</a>
</div>
</div> </div>
{% if show_series %} {% if show_series %}
<div class="section"> <div class="section">
<h2>Laufende Serien</h2> <h2>Ongoing series</h2>
<div class="grid"> <div class="grid">
{% for s in series_grouped %} {% for s in series_grouped %}
<div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}" <div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}"
@@ -60,7 +64,7 @@
<span class="muted" data-dt="{{ e.airDateUtc|default:'' }}"></span> <span class="muted" data-dt="{{ e.airDateUtc|default:'' }}"></span>
</div> </div>
{% empty %} {% empty %}
<div class="muted">Keine kommenden Episoden.</div> <div class="muted">No upcoming episodes.</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -72,7 +76,7 @@
{% endwith %} {% endwith %}
</div> </div>
{% empty %} {% empty %}
<p class="muted">Keine Serien gefunden.</p> <p class="muted">No series found.</p>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -80,7 +84,7 @@
{% if show_movies %} {% if show_movies %}
<div class="section"> <div class="section">
<h2>Anstehende Filme</h2> <h2>Upcoming movies</h2>
<div class="grid"> <div class="grid">
{% for m in movies %} {% for m in movies %}
<div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}" <div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}"
@@ -92,13 +96,13 @@
{% endif %} {% endif %}
<div class="title">{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}</div> <div class="title">{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}</div>
<div class="muted"> <div class="muted">
{% if m.inCinemas %}Kino: <span data-dt="{{ m.inCinemas }}"></span>{% endif %} {% if m.inCinemas %}In theaters: <span data-dt="{{ m.inCinemas }}"></span>{% endif %}
{% if m.digitalRelease %}<br>Digital: <span data-dt="{{ m.digitalRelease }}"></span>{% endif %} {% if m.digitalRelease %}<br>Digital: <span data-dt="{{ m.digitalRelease }}"></span>{% endif %}
{% if m.physicalRelease %}<br>Disc: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %} {% if m.physicalRelease %}<br>Physical: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %}
</div> </div>
</div> </div>
{% empty %} {% empty %}
<p class="muted">Keine Filme gefunden.</p> <p class="muted">No movies found.</p>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -118,17 +122,17 @@
<div id="mSub" class="m-sub"></div> <div id="mSub" class="m-sub"></div>
<div id="mBadges" class="badges"></div> <div id="mBadges" class="badges"></div>
</div> </div>
<button class="modal-close" title="Schließen" aria-label="Schließen">&times;</button> <button class="modal-close" title="Close" aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="section-block"> <div class="section-block">
<div class="section-title">Beschreibung</div> <div class="section-title">Overview</div>
<div id="mOverview" class="desc muted"></div> <div id="mOverview" class="desc muted"></div>
</div> </div>
<div class="section-block"> <div class="section-block">
<div class="section-title">Kommende Episoden</div> <div class="section-title">Upcoming episodes</div>
<div class="section-divider"></div> <div class="section-divider"></div>
<div id="mEpisodes"></div> <div id="mEpisodes"></div>
</div> </div>
@@ -153,6 +157,7 @@
const mSub = $("#mSub"); const mSub = $("#mSub");
const epSection = mEpisodes.closest(".section-block"); const epSection = mEpisodes.closest(".section-block");
const subscribeBtn = $("#subscribeBtn"); const subscribeBtn = $("#subscribeBtn");
const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('subscribarr-sub') : null;
let lastClickedCard = null; let lastClickedCard = null;
@@ -178,7 +183,7 @@
return "movie:" + (card.dataset.title || ""); return "movie:" + (card.dataset.title || "");
} }
// Cache für Abonnement-Status // Cache for subscription state
const subCache = new Map(); const subCache = new Map();
async function loadAllSubs() { async function loadAllSubs() {
@@ -207,7 +212,7 @@
return k ? subCache.get(k) || false : false; return k ? subCache.get(k) || false : false;
} }
async function saveSub(card, on) { async function saveSub(card, on) {
const k = subKey(card); const k = subKey(card);
if (!k) return; if (!k) return;
const [type, id] = k.split(":"); const [type, id] = k.split(":");
@@ -221,45 +226,48 @@
}); });
if (resp.status === 403) { if (resp.status === 403) {
// Nicht eingeloggt // Not logged in
window.location.href = '/accounts/login/?next=' + encodeURIComponent(window.location.pathname); window.location.href = '/accounts/login/?next=' + encodeURIComponent(window.location.pathname);
return; return;
} }
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
// Cache aktualisieren // Update cache
if (on) { if (on) {
subCache.set(k, true); subCache.set(k, true);
} else { } else {
subCache.delete(k); subCache.delete(k);
} }
// Cross-tab/page notify
if (bc) bc.postMessage({ type: 'sub_change', kind: type, key: id, on });
} catch (err) { } catch (err) {
console.error("Failed to update subscription:", err); console.error("Failed to update subscription:", err);
// Cache-Update rückgängig machen bei Fehler // Revert optimistic cache on error
if (on) { if (on) {
subCache.delete(k); subCache.delete(k);
} else { } else {
subCache.set(k, true); subCache.set(k, true);
} }
// Fehlermeldung anzeigen // Show error
const errorMsg = document.createElement('div'); const errorMsg = document.createElement('div');
errorMsg.className = 'error-message'; errorMsg.className = 'error-message';
errorMsg.textContent = 'Fehler beim Aktualisieren des Abonnements. Bitte versuchen Sie es später erneut.'; errorMsg.textContent = 'Failed to update subscription. Please try again later.';
document.body.appendChild(errorMsg); document.body.appendChild(errorMsg);
setTimeout(() => errorMsg.remove(), 3000); setTimeout(() => errorMsg.remove(), 3000);
} }
} }
function applySubUI(card, on) { function applySubUI(card, on) {
if (card) card.classList.toggle("subscribed", !!on); // grüne Outline via .subscribed (CSS) if (card) card.classList.toggle("subscribed", !!on); // green outline via .subscribed (CSS)
if (subscribeBtn) { if (subscribeBtn) {
subscribeBtn.textContent = on ? "Unsubscribe" : "Subscribe"; subscribeBtn.textContent = on ? "Unsubscribe" : "Subscribe";
subscribeBtn.setAttribute("aria-pressed", on ? "true" : "false"); subscribeBtn.setAttribute("aria-pressed", on ? "true" : "false");
} }
} }
// Beim Laden: Alle Abonnements in einem API-Call laden // On load: fetch all subscriptions in a single API call
(async () => { (async () => {
await loadAllSubs(); await loadAllSubs();
const cards = $$(".card, .movie-card"); const cards = $$(".card, .movie-card");
@@ -268,7 +276,30 @@
}); });
})(); })();
// ===== Serien-Karten öffnen ===== // Listen to subscription changes from other pages (e.g., calendar)
if (bc) {
bc.onmessage = (evt) => {
const msg = evt.data || {};
if (msg.type !== 'sub_change') return;
const { kind, key, on } = msg;
// Keep cache in sync
const cacheKey = `${kind}:${key}`;
if (on) subCache.set(cacheKey, true); else subCache.delete(cacheKey);
// Update matching cards
const cards = $$(".card, .movie-card");
cards.forEach(card => {
const isSeries = card.classList.contains('card') && card.dataset.seriesId;
const isMovie = card.classList.contains('movie-card') && card.dataset.title;
let match = false;
if (kind === 'series' && isSeries && String(card.dataset.seriesId) === String(key)) match = true;
if (kind === 'movie' && isMovie && (card.dataset.title || '') === key) match = true;
if (match) applySubUI(card, on);
});
};
}
// ===== Open series cards =====
$$(".card").forEach(card => { $$(".card").forEach(card => {
card.addEventListener("click", () => { card.addEventListener("click", () => {
lastClickedCard = card; lastClickedCard = card;
@@ -278,22 +309,22 @@
const poster = card.dataset.poster || ""; const poster = card.dataset.poster || "";
const overview = card.dataset.overview || ""; const overview = card.dataset.overview || "";
// Episoden aus eingebettetem JSON <script id="eps-<id>"> // Episodes from embedded JSON <script id="eps-<id>">
let episodes = []; let episodes = [];
const script = document.getElementById("eps-" + id); const script = document.getElementById("eps-" + id);
if (script) { try { episodes = JSON.parse(script.textContent); } catch { } } if (script) { try { episodes = JSON.parse(script.textContent); } catch { } }
// Modal befüllen // Fill modal
mTitle.textContent = title; mTitle.textContent = title;
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster"; mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
mPoster.alt = title; mPoster.alt = title;
mOverview.textContent = overview || "Keine Beschreibung verfügbar."; mOverview.textContent = overview || "No overview available.";
mSub.textContent = episodes.length mSub.textContent = episodes.length
? `${episodes.length} kommende Episode(n)` ? `${episodes.length} upcoming episode(s)`
: "Keine kommenden Episoden"; : "No upcoming episodes";
// Genres-Badges, falls data-genres vorhanden // Genre badges if data-genres is present
mBadges.innerHTML = ""; mBadges.innerHTML = "";
if (card.dataset.genres) { if (card.dataset.genres) {
card.dataset.genres.split(",").map(s => s.trim()).filter(Boolean).forEach(g => { card.dataset.genres.split(",").map(s => s.trim()).filter(Boolean).forEach(g => {
@@ -304,7 +335,7 @@
}); });
} }
// Episodenbereich // Episodes section
epSection.style.display = ""; epSection.style.display = "";
mEpisodes.innerHTML = ""; mEpisodes.innerHTML = "";
if (!episodes.length) { if (!episodes.length) {
@@ -323,10 +354,10 @@
}); });
} }
// Subscribe-UI für diese Karte setzen // Set subscribe UI for this card
applySubUI(lastClickedCard, loadSub(lastClickedCard)); applySubUI(lastClickedCard, loadSub(lastClickedCard));
// Status nochmal aktualisieren zur Sicherheit // Refresh status again for safety
loadAllSubs().then(() => { loadAllSubs().then(() => {
applySubUI(lastClickedCard, loadSub(lastClickedCard)); applySubUI(lastClickedCard, loadSub(lastClickedCard));
}); });
@@ -335,7 +366,7 @@
}); });
}); });
// ===== Film-Karten öffnen ===== // ===== Open movie cards =====
$$(".movie-card").forEach(card => { $$(".movie-card").forEach(card => {
card.addEventListener("click", () => { card.addEventListener("click", () => {
lastClickedCard = card; lastClickedCard = card;
@@ -347,19 +378,19 @@
mTitle.textContent = title; mTitle.textContent = title;
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster"; mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
mPoster.alt = title; mPoster.alt = title;
mOverview.textContent = overview || "Keine Beschreibung verfügbar."; mOverview.textContent = overview || "No overview available.";
mSub.textContent = ""; mSub.textContent = "";
mBadges.innerHTML = ""; mBadges.innerHTML = "";
// Episodenbereich ausblenden // Hide episodes section
epSection.style.display = "none"; epSection.style.display = "none";
mEpisodes.innerHTML = ""; mEpisodes.innerHTML = "";
// Subscribe-UI für diese Karte setzen // Set subscribe UI for this card
applySubUI(lastClickedCard, loadSub(lastClickedCard)); applySubUI(lastClickedCard, loadSub(lastClickedCard));
// Status nochmal aktualisieren zur Sicherheit // Refresh status again for safety
loadAllSubs().then(() => { loadAllSubs().then(() => {
applySubUI(lastClickedCard, loadSub(lastClickedCard)); applySubUI(lastClickedCard, loadSub(lastClickedCard));
}); });
@@ -368,7 +399,7 @@
}); });
}); });
// ===== Subscribe-Button im Modal mit Backend-Sync ===== // ===== Subscribe button in modal with backend sync =====
if (subscribeBtn) { if (subscribeBtn) {
subscribeBtn.addEventListener("click", async () => { subscribeBtn.addEventListener("click", async () => {
if (!lastClickedCard) return; if (!lastClickedCard) return;
@@ -378,16 +409,16 @@
// Optimistic UI update // Optimistic UI update
applySubUI(lastClickedCard, newState); applySubUI(lastClickedCard, newState);
// Backend-Sync // Backend sync
await saveSub(lastClickedCard, newState); await saveSub(lastClickedCard, newState);
// Status neu laden zur Sicherheit // Refresh status again for safety
const finalState = await loadSub(lastClickedCard); const finalState = await loadSub(lastClickedCard);
applySubUI(lastClickedCard, finalState); applySubUI(lastClickedCard, finalState);
}); });
} }
// ===== Datumsangaben in der Übersicht formatieren ===== // ===== Format date/time labels in the overview =====
document.querySelectorAll("[data-dt]").forEach(el => { document.querySelectorAll("[data-dt]").forEach(el => {
const v = el.getAttribute("data-dt"); const v = el.getAttribute("data-dt");
if (!v) return; if (!v) return;

View File

@@ -2,13 +2,17 @@ from django.urls import path
from .views import ( from .views import (
ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView, ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView,
MovieSubscribeView, MovieUnsubscribeView, MovieSubscribeView, MovieUnsubscribeView,
ListSeriesSubscriptionsView, ListMovieSubscriptionsView ListSeriesSubscriptionsView, ListMovieSubscriptionsView,
CalendarView, CalendarEventsApi,
) )
app_name = 'arr_api' app_name = 'arr_api'
urlpatterns = [ urlpatterns = [
path('', ArrIndexView.as_view(), name='index'), path('', ArrIndexView.as_view(), name='index'),
# Calendar
path('calendar/', CalendarView.as_view(), name='calendar'),
path('api/calendar/events/', CalendarEventsApi.as_view(), name='calendar-events'),
# Series URLs # Series URLs
path('api/series/subscribe/<int:series_id>/', SeriesSubscribeView.as_view(), name='subscribe-series'), path('api/series/subscribe/<int:series_id>/', SeriesSubscribeView.as_view(), name='subscribe-series'),

View File

@@ -13,6 +13,7 @@ from rest_framework import status
from settingspanel.models import AppSettings from settingspanel.models import AppSettings
from .services import sonarr_calendar, radarr_calendar, ArrServiceError from .services import sonarr_calendar, radarr_calendar, ArrServiceError
from .models import SeriesSubscription, MovieSubscription from .models import SeriesSubscription, MovieSubscription
from django.utils import timezone
def _get_int(request, key, default): def _get_int(request, key, default):
@@ -67,13 +68,13 @@ class ArrIndexView(View):
try: try:
eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"]) eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
except ArrServiceError as e: except ArrServiceError as e:
messages.error(request, f"Sonarr nicht erreichbar: {e}") messages.error(request, f"Sonarr is not reachable: {e}")
# Radarr robust laden # Radarr robust laden
try: try:
movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"]) movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
except ArrServiceError as e: except ArrServiceError as e:
messages.error(request, f"Radarr nicht erreichbar: {e}") messages.error(request, f"Radarr is not reachable: {e}")
# Suche # Suche
if q: if q:
@@ -128,6 +129,74 @@ class ArrIndexView(View):
}) })
class CalendarView(View):
def get(self, request):
days = _get_int(request, "days", 60)
return render(request, "arr_api/calendar.html", {"days": days})
@method_decorator(login_required, name='dispatch')
class CalendarEventsApi(APIView):
def get(self, request):
days = _get_int(request, "days", 60)
conf = _arr_conf_from_db()
try:
eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
except ArrServiceError:
eps = []
try:
movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
except ArrServiceError:
movies = []
series_sub = set(SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True))
movie_sub_titles = set(MovieSubscription.objects.filter(user=request.user).values_list('title', flat=True))
events = []
for e in eps:
when = e.get("airDateUtc")
if not when:
continue
events.append({
"id": f"s:{e.get('seriesId')}:{e.get('episodeId')}",
"title": f"{e.get('seriesTitle','')} — S{e.get('seasonNumber')}E{e.get('episodeNumber')}",
"start": when,
"allDay": False,
"extendedProps": {
"kind": "series",
"seriesId": e.get('seriesId'),
"seriesTitle": e.get('seriesTitle'),
"seasonNumber": e.get('seasonNumber'),
"episodeNumber": e.get('episodeNumber'),
"episodeTitle": e.get('title'),
"overview": e.get('seriesOverview') or "",
"poster": e.get('seriesPoster') or "",
"subscribed": int(e.get('seriesId') or 0) in series_sub,
}
})
for m in movies:
when = m.get('digitalRelease') or m.get('physicalRelease') or m.get('inCinemas')
if not when:
continue
events.append({
"id": f"m:{m.get('movieId') or m.get('title')}",
"title": m.get('title') or "(movie)",
"start": when,
"allDay": True,
"extendedProps": {
"kind": "movie",
"movieId": m.get('movieId'),
"title": m.get('title'),
"overview": m.get('overview') or "",
"poster": m.get('posterUrl') or "",
"subscribed": (m.get('title') or '') in movie_sub_titles,
}
})
return Response({"events": events})
class SubscribeSeriesView(View): class SubscribeSeriesView(View):
@method_decorator(require_POST) @method_decorator(require_POST)
def post(self, request, series_id): def post(self, request, series_id):
@@ -145,9 +214,9 @@ class SubscribeSeriesView(View):
) )
if created: if created:
messages.success(request, f'Serie "{series_data["series_title"]}" wurde abonniert!') messages.success(request, f'Subscribed to series "{series_data["series_title"]}"!')
else: else:
messages.info(request, f'Serie "{series_data["series_title"]}" war bereits abonniert.') messages.info(request, f'Series "{series_data["series_title"]}" was already subscribed.')
return redirect('arr_api:index') return redirect('arr_api:index')
@@ -157,7 +226,7 @@ class UnsubscribeSeriesView(View):
subscription = get_object_or_404(SeriesSubscription, series_id=series_id) subscription = get_object_or_404(SeriesSubscription, series_id=series_id)
series_title = subscription.series_title series_title = subscription.series_title
subscription.delete() subscription.delete()
messages.success(request, f'Abonnement für "{series_title}" wurde beendet.') messages.success(request, f'Subscription for "{series_title}" has been removed.')
return redirect('arr_api:index') return redirect('arr_api:index')
class SubscribeMovieView(View): class SubscribeMovieView(View):
@@ -178,9 +247,9 @@ class SubscribeMovieView(View):
) )
if created: if created:
messages.success(request, f'Film "{movie_data["title"]}" wurde abonniert!') messages.success(request, f'Subscribed to movie "{movie_data["title"]}"!')
else: else:
messages.info(request, f'Film "{movie_data["title"]}" war bereits abonniert.') messages.info(request, f'Movie "{movie_data["title"]}" was already subscribed.')
return redirect('arr_api:index') return redirect('arr_api:index')
@@ -190,14 +259,14 @@ class UnsubscribeMovieView(View):
subscription = get_object_or_404(MovieSubscription, movie_id=movie_id) subscription = get_object_or_404(MovieSubscription, movie_id=movie_id)
movie_title = subscription.title movie_title = subscription.title
subscription.delete() subscription.delete()
messages.success(request, f'Abonnement für "{movie_title}" wurde beendet.') messages.success(request, f'Subscription for "{movie_title}" has been removed.')
return redirect('arr_api:index') return redirect('arr_api:index')
@require_POST @require_POST
@login_required @login_required
def subscribe_series(request, series_id): def subscribe_series(request, series_id):
"""Serie abonnieren""" """Subscribe to a series"""
try: try:
# Existiert bereits? # Existiert bereits?
if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists(): if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists():
@@ -224,7 +293,7 @@ def subscribe_series(request, series_id):
@require_POST @require_POST
@login_required @login_required
def unsubscribe_series(request, series_id): def unsubscribe_series(request, series_id):
"""Serie deabonnieren""" """Unsubscribe from a series"""
try: try:
SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete() SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete()
return JsonResponse({'success': True}) return JsonResponse({'success': True})
@@ -233,14 +302,14 @@ def unsubscribe_series(request, series_id):
@login_required @login_required
def is_subscribed_series(request, series_id): def is_subscribed_series(request, series_id):
"""Prüfe ob Serie abonniert ist""" """Check if a series is subscribed"""
is_subbed = SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists() is_subbed = SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists()
return JsonResponse({'subscribed': is_subbed}) return JsonResponse({'subscribed': is_subbed})
@require_POST @require_POST
@login_required @login_required
def subscribe_movie(request, movie_id): def subscribe_movie(request, movie_id):
"""Film abonnieren""" """Subscribe to a movie"""
try: try:
# Existiert bereits? # Existiert bereits?
if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists(): if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists():
@@ -268,7 +337,7 @@ def subscribe_movie(request, movie_id):
@require_POST @require_POST
@login_required @login_required
def unsubscribe_movie(request, movie_id): def unsubscribe_movie(request, movie_id):
"""Film deabonnieren""" """Unsubscribe from a movie"""
try: try:
MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete() MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete()
return JsonResponse({'success': True}) return JsonResponse({'success': True})
@@ -277,13 +346,13 @@ def unsubscribe_movie(request, movie_id):
@login_required @login_required
def is_subscribed_movie(request, movie_id): def is_subscribed_movie(request, movie_id):
"""Prüfe ob Film abonniert ist""" """Check if a movie is subscribed"""
is_subbed = MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists() is_subbed = MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists()
return JsonResponse({'subscribed': is_subbed}) return JsonResponse({'subscribed': is_subbed})
@login_required @login_required
def get_subscriptions(request): def get_subscriptions(request):
"""Hole alle Abonnements des Users""" """Get all subscriptions for the user"""
series = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True) series = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True)
movies = MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True) movies = MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True)
return JsonResponse({ return JsonResponse({

View File

@@ -4,7 +4,7 @@ services:
build: . build: .
container_name: subscribarr container_name: subscribarr
ports: ports:
- "8000:8000" - "8081:8000"
environment: environment:
# Django # Django
- DJANGO_DEBUG=true - DJANGO_DEBUG=true
@@ -13,22 +13,22 @@ services:
- DB_PATH=/app/data/db.sqlite3 - DB_PATH=/app/data/db.sqlite3
- NOTIFICATIONS_ALLOW_DUPLICATES=false - NOTIFICATIONS_ALLOW_DUPLICATES=false
# App Settings (optional, otherwise use first-run setup) # App Settings (optional, otherwise use first-run setup)
- JELLYFIN_URL= #- JELLYFIN_URL=
- JELLYFIN_API_KEY= #- JELLYFIN_API_KEY=
- SONARR_URL= #- SONARR_URL=
- SONARR_API_KEY= #- SONARR_API_KEY=
- RADARR_URL= #- RADARR_URL=
- RADARR_API_KEY= #- RADARR_API_KEY=
- MAIL_HOST= #- MAIL_HOST=
- MAIL_PORT= #- MAIL_PORT=
- MAIL_SECURE= #- MAIL_SECURE=
- MAIL_USER= #- MAIL_USER=
- MAIL_PASSWORD= #- MAIL_PASSWORD=
- MAIL_FROM= #- MAIL_FROM=
# Admin bootstrap (optional) # Admin bootstrap (optional)
- ADMIN_USERNAME= #- ADMIN_USERNAME=
- ADMIN_PASSWORD= #- ADMIN_PASSWORD=
- ADMIN_EMAIL= #- ADMIN_EMAIL=
# Cron schedule (default every 30min) # Cron schedule (default every 30min)
- CRON_SCHEDULE=*/30 * * * * - CRON_SCHEDULE=*/30 * * * *
volumes: volumes:

View File

@@ -7,20 +7,20 @@ class FirstRunSetupForm(forms.Form):
jellyfin_server_url = forms.URLField( jellyfin_server_url = forms.URLField(
label="Jellyfin Server URL", label="Jellyfin Server URL",
required=True, required=True,
help_text="Die URL deines Jellyfin-Servers" help_text="URL of your Jellyfin server"
) )
jellyfin_api_key = forms.CharField( jellyfin_api_key = forms.CharField(
label="Jellyfin API Key", label="Jellyfin API Key",
required=True, required=True,
widget=forms.PasswordInput(render_value=True), widget=forms.PasswordInput(render_value=True),
help_text="Der API-Key aus den Jellyfin-Einstellungen" help_text="API key from Jellyfin settings"
) )
# Sonarr (Optional) # Sonarr (Optional)
sonarr_url = forms.URLField( sonarr_url = forms.URLField(
label="Sonarr URL", label="Sonarr URL",
required=False, required=False,
help_text="Die URL deines Sonarr-Servers" help_text="URL of your Sonarr server"
) )
sonarr_api_key = forms.CharField( sonarr_api_key = forms.CharField(
label="Sonarr API Key", label="Sonarr API Key",
@@ -32,7 +32,7 @@ class FirstRunSetupForm(forms.Form):
radarr_url = forms.URLField( radarr_url = forms.URLField(
label="Radarr URL", label="Radarr URL",
required=False, required=False,
help_text="Die URL deines Radarr-Servers" help_text="URL of your Radarr server"
) )
radarr_api_key = forms.CharField( radarr_api_key = forms.CharField(
label="Radarr API Key", label="Radarr API Key",
@@ -45,13 +45,13 @@ class JellyfinSettingsForm(forms.Form):
label="Jellyfin Server URL", label="Jellyfin Server URL",
required=False, required=False,
widget=forms.URLInput(attrs=WIDE), widget=forms.URLInput(attrs=WIDE),
help_text="z.B. http://localhost:8096" help_text="e.g. http://localhost:8096"
) )
jellyfin_api_key = forms.CharField( jellyfin_api_key = forms.CharField(
label="Jellyfin API Key", label="Jellyfin API Key",
required=False, required=False,
widget=forms.PasswordInput(render_value=True, attrs=WIDE), widget=forms.PasswordInput(render_value=True, attrs=WIDE),
help_text="Admin API Key aus den Jellyfin Einstellungen" help_text="Admin API key from Jellyfin settings"
) )
class ArrSettingsForm(forms.Form): class ArrSettingsForm(forms.Form):
@@ -68,18 +68,18 @@ class MailSettingsForm(forms.Form):
mail_host = forms.CharField(label="Mail Host", required=False) mail_host = forms.CharField(label="Mail Host", required=False)
mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535) mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535)
mail_secure = forms.ChoiceField( mail_secure = forms.ChoiceField(
label="Sicherheit", required=False, label="Security", required=False,
choices=[("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")] choices=[("", "No TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")]
) )
mail_user = forms.CharField(label="Mail Benutzer", required=False) mail_user = forms.CharField(label="Mail Username", required=False)
mail_password = forms.CharField( mail_password = forms.CharField(
label="Mail Passwort", required=False, label="Mail Password", required=False,
widget=forms.PasswordInput(render_value=True) widget=forms.PasswordInput(render_value=True)
) )
mail_from = forms.EmailField(label="Absender (From)", required=False) mail_from = forms.EmailField(label="Sender (From)", required=False)
class AccountForm(forms.Form): class AccountForm(forms.Form):
username = forms.CharField(label="Benutzername", required=False) username = forms.CharField(label="Username", required=False)
email = forms.EmailField(label="E-Mail", required=False) email = forms.EmailField(label="Email", required=False)
new_password = forms.CharField(label="Neues Passwort", required=False, widget=forms.PasswordInput) new_password = forms.CharField(label="New password", required=False, widget=forms.PasswordInput)
repeat_password = forms.CharField(label="Passwort wiederholen", required=False, widget=forms.PasswordInput) repeat_password = forms.CharField(label="Repeat password", required=False, widget=forms.PasswordInput)

View File

@@ -1,7 +1,7 @@
from django.db import models from django.db import models
class AppSettings(models.Model): class AppSettings(models.Model):
# Singleton-Pattern über feste ID # Singleton pattern via fixed ID
singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False) singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
# Jellyfin # Jellyfin
@@ -20,7 +20,7 @@ class AppSettings(models.Model):
mail_secure = models.CharField( mail_secure = models.CharField(
max_length=10, blank=True, null=True, max_length=10, blank=True, null=True,
choices=( choices=(
("", "Kein TLS/SSL"), ("", "No TLS/SSL"),
("starttls", "STARTTLS (Port 587)"), ("starttls", "STARTTLS (Port 587)"),
("ssl", "SSL/TLS (Port 465)"), ("ssl", "SSL/TLS (Port 465)"),
("tls", "TLS (alias STARTTLS)"), ("tls", "TLS (alias STARTTLS)"),
@@ -30,7 +30,7 @@ class AppSettings(models.Model):
mail_password = models.CharField(max_length=255, blank=True, null=True) mail_password = models.CharField(max_length=255, blank=True, null=True)
mail_from = models.EmailField(blank=True, null=True) mail_from = models.EmailField(blank=True, null=True)
# Account # Account
acc_username = models.CharField(max_length=150, blank=True, null=True) acc_username = models.CharField(max_length=150, blank=True, null=True)
acc_email = models.EmailField(blank=True, null=True) acc_email = models.EmailField(blank=True, null=True)

View File

@@ -7,28 +7,28 @@
{% block content %} {% block content %}
<div class="setup-container"> <div class="setup-container">
<h1>Willkommen bei Subscribarr</h1> <h1>Welcome to Subscribarr</h1>
<p class="setup-intro">Lass uns deine Installation einrichten. Du brauchst mindestens einen Jellyfin-Server.</p> <p class="setup-intro">Let's set up your installation. You need at least one Jellyfin server.</p>
<form method="post" class="setup-form"> <form method="post" class="setup-form">
{% csrf_token %} {% csrf_token %}
<div class="setup-section"> <div class="setup-section">
<h2>Jellyfin Server (Erforderlich)</h2> <h2>Jellyfin server (required)</h2>
<div class="form-group"> <div class="form-group">
<label>Server URL</label> <label>Server URL</label>
{{ form.jellyfin_server_url }} {{ form.jellyfin_server_url }}
<div class="help">z.B. http://192.168.1.100:8096 oder http://jellyfin.local:8096</div> <div class="help">e.g., http://192.168.1.100:8096 or http://jellyfin.local:8096</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>API Key</label> <label>API Key</label>
{{ form.jellyfin_api_key }} {{ form.jellyfin_api_key }}
<div class="help">Admin API Key aus den Jellyfin-Einstellungen</div> <div class="help">Admin API key from Jellyfin settings</div>
</div> </div>
</div> </div>
<div class="setup-section"> <div class="setup-section">
<h2>Sonarr (Optional)</h2> <h2>Sonarr (optional)</h2>
<div class="form-group"> <div class="form-group">
<label>Server URL</label> <label>Server URL</label>
{{ form.sonarr_url }} {{ form.sonarr_url }}
@@ -40,7 +40,7 @@
</div> </div>
<div class="setup-section"> <div class="setup-section">
<h2>Radarr (Optional)</h2> <h2>Radarr (optional)</h2>
<div class="form-group"> <div class="form-group">
<label>Server URL</label> <label>Server URL</label>
{{ form.radarr_url }} {{ form.radarr_url }}
@@ -51,7 +51,7 @@
</div> </div>
</div> </div>
<button type="submit" class="setup-submit">Installation abschließen</button> <button type="submit" class="setup-submit">Finish setup</button>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,19 +1,22 @@
{% load static %} {% load static %}
<!doctype html> <!doctype html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einstellungen Subscribarr</title> <title>Settings Subscribarr</title>
<link rel="stylesheet" href="{% static 'css/settings.css' %}"> <link rel="stylesheet" href="{% static 'css/settings.css' %}">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<div class="topbar"> <div class="topbar">
<div><a href="/" class="btn">← Zurück</a></div> <div style="display:flex; gap:8px; align-items:center;">
<div><strong>Einstellungen</strong></div> <a href="/" class="btn">← Back</a>
<a href="{% url 'settingspanel:subscriptions' %}" class="btn">👥 Subscriptions</a>
</div>
<div><strong>Settings</strong></div>
<div></div> <div></div>
</div> </div>
@@ -29,12 +32,12 @@
<div class="row"> <div class="row">
<label>Jellyfin Server URL</label> <label>Jellyfin Server URL</label>
{{ jellyfin_form.jellyfin_server_url }} {{ jellyfin_form.jellyfin_server_url }}
<div class="help">z.B. http://localhost:8096</div> <div class="help">e.g., http://localhost:8096</div>
</div> </div>
<div class="row"> <div class="row">
<label>Jellyfin API Key</label> <label>Jellyfin API Key</label>
{{ jellyfin_form.jellyfin_api_key }} {{ jellyfin_form.jellyfin_api_key }}
<div class="help">Admin API Key aus den Jellyfin Einstellungen</div> <div class="help">Admin API key from your Jellyfin settings</div>
</div> </div>
</div> </div>
@@ -46,8 +49,7 @@
<div class="inline"> <div class="inline">
<div class="field">{{ arr_form.sonarr_url }}</div> <div class="field">{{ arr_form.sonarr_url }}</div>
<div class="inline-actions"> <div class="inline-actions">
<button class="btn" type="button" onclick="testConnection('sonarr', this)">Test <button class="btn" type="button" onclick="testConnection('sonarr', this)">Test Sonarr</button>
Sonarr</button>
<span id="sonarrStatus" class="badge muted"></span> <span id="sonarrStatus" class="badge muted"></span>
</div> </div>
</div> </div>
@@ -64,8 +66,7 @@
<div class="inline"> <div class="inline">
<div class="field">{{ arr_form.radarr_url }}</div> <div class="field">{{ arr_form.radarr_url }}</div>
<div class="inline-actions"> <div class="inline-actions">
<button class="btn" type="button" onclick="testConnection('radarr', this)">Test <button class="btn" type="button" onclick="testConnection('radarr', this)">Test Radarr</button>
Radarr</button>
<span id="radarrStatus" class="badge muted"></span> <span id="radarrStatus" class="badge muted"></span>
</div> </div>
</div> </div>
@@ -76,32 +77,31 @@
{{ arr_form.radarr_api_key }} {{ arr_form.radarr_api_key }}
</div> </div>
<div class="help">Klicke „Test …“, um die Verbindung gegen <code>/api/v3/system/status</code> zu <div class="help">ClickTest …” to verify against <code>/api/v3/system/status</code>.</div>
prüfen.</div>
</div> </div>
<div class="card"> <div class="card">
<h2>Mailserver</h2> <h2>Mail server</h2>
<div class="row"><label>Host</label>{{ mail_form.mail_host }}</div> <div class="row"><label>Host</label>{{ mail_form.mail_host }}</div>
<div class="row"><label>Port</label>{{ mail_form.mail_port }}</div> <div class="row"><label>Port</label>{{ mail_form.mail_port }}</div>
<div class="row"><label>Sicherheit</label>{{ mail_form.mail_secure }}</div> <div class="row"><label>Security</label>{{ mail_form.mail_secure }}</div>
<div class="row"><label>Benutzer</label>{{ mail_form.mail_user }}</div> <div class="row"><label>User</label>{{ mail_form.mail_user }}</div>
<div class="row"><label>Passwort</label>{{ mail_form.mail_password }}</div> <div class="row"><label>Password</label>{{ mail_form.mail_password }}</div>
<div class="row"><label>Absender</label>{{ mail_form.mail_from }}</div> <div class="row"><label>From</label>{{ mail_form.mail_from }}</div>
</div> </div>
<div class="card"> <div class="card">
<h2>Konto</h2> <h2>Account</h2>
<div class="row"><label>Benutzername</label>{{ account_form.username }}</div> <div class="row"><label>Username</label>{{ account_form.username }}</div>
<div class="row"><label>E-Mail</label>{{ account_form.email }}</div> <div class="row"><label>Email</label>{{ account_form.email }}</div>
<div class="row"><label>Neues Passwort</label>{{ account_form.new_password }}</div> <div class="row"><label>New password</label>{{ account_form.new_password }}</div>
<div class="row"><label>Passwort wiederholen</label>{{ account_form.repeat_password }}</div> <div class="row"><label>Repeat password</label>{{ account_form.repeat_password }}</div>
<div class="help">Nur Oberfläche Umsetzung Passwortänderung später.</div> <div class="help">Only UI implementing password change later.</div>
</div> </div>
</div> </div>
<div style="margin-top:16px"> <div style="margin-top:16px">
<button class="btn btn-primary" type="submit">Speichern</button> <button class="btn btn-primary" type="submit">Save</button>
</div> </div>
</form> </form>
</div> </div>
@@ -116,25 +116,25 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.ok) { if (data.ok) {
alert(kind + " Verbindung erfolgreich!"); alert(kind + " connection successful!");
} else { } else {
alert(kind + " Fehler: " + data.error); alert(kind + " error: " + data.error);
} }
}) })
.catch(err => alert(kind + " Fehler: " + err)); .catch(err => alert(kind + " error: " + err));
} }
function setBadge(kind, state, text, tooltip) { function setBadge(kind, state, text, tooltip) {
const el = document.getElementById(kind + "Status"); const el = document.getElementById(kind + "Status");
if (!el) return; if (!el) return;
el.classList.remove("ok", "err", "muted"); el.classList.remove("ok", "err", "muted");
el.title = tooltip || ""; // voller Fehlertext im Tooltip el.title = tooltip || ""; // full error text in tooltip
if (state === "ok") { if (state === "ok") {
el.classList.add("ok"); el.classList.add("ok");
el.textContent = "Verbunden"; el.textContent = "Connected";
} else if (state === "err") { } else if (state === "err") {
el.classList.add("err"); el.classList.add("err");
el.textContent = "Fehler"; el.textContent = "Error";
} else { } else {
el.classList.add("muted"); el.classList.add("muted");
el.textContent = "—"; el.textContent = "—";
@@ -147,19 +147,19 @@
const url = urlEl ? urlEl.value.trim() : ""; const url = urlEl ? urlEl.value.trim() : "";
const key = keyEl ? keyEl.value.trim() : ""; const key = keyEl ? keyEl.value.trim() : "";
setBadge(kind, "muted", "Teste…"); setBadge(kind, "muted", "Testing…");
if (btnEl) { btnEl.disabled = true; } if (btnEl) { btnEl.disabled = true; }
fetch(`/settings/test-connection/?kind=${encodeURIComponent(kind)}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`) fetch(`/settings/test-connection/?kind=${encodeURIComponent(kind)}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.ok) { if (data.ok) {
setBadge(kind, "ok", "Verbunden", ""); setBadge(kind, "ok", "Connected", "");
} else { } else {
setBadge(kind, "err", "Fehler", data.error || "Unbekannter Fehler"); setBadge(kind, "err", "Error", data.error || "Unknown error");
} }
}) })
.catch(err => setBadge(kind, "err", "Fehler", String(err))) .catch(err => setBadge(kind, "err", "Error", String(err)))
.finally(() => { if (btnEl) btnEl.disabled = false; }); .finally(() => { if (btnEl) btnEl.disabled = false; });
} }

View File

@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}Subscriptions Admin{% endblock %}
{% block extra_style %}
<style>
.wrap { max-width: 1200px; margin: 0 auto; padding: 16px; }
.filters { display:flex; gap:8px; margin: 10px 0 16px; }
.filters input { padding: 10px 12px; border-radius: 10px; border: 1px solid #2a2a34; background:#111119; color:#e6e6e6; min-width: 240px; }
.section { margin-top: 20px; }
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.card { background:#12121a; border:1px solid #1f2030; border-radius:12px; padding:12px; display:flex; gap:12px; }
.poster { width: 90px; height: 135px; border-radius:8px; overflow:hidden; background:#222233; flex:0 0 auto; }
.poster img { width:100%; height:100%; object-fit: cover; display:block; }
.meta { flex:1 1 auto; min-width:0; }
.title { font-weight:600; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.muted { color:#9aa0b4; font-size:.9rem; }
.user { font-weight:600; }
/* user list */
.users { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:10px; }
.user-item { background:#10121b; border:1px solid #1f2030; border-radius:10px; padding:10px 12px; display:flex; justify-content:space-between; align-items:center; cursor:pointer; transition: transform .08s ease, border-color .12s ease; }
.user-item:hover { transform: translateY(-1px); border-color:#2a2b44; }
.badge { background:#171a26; border:1px solid #2a2b44; color:#cfd3ea; border-radius:999px; padding:2px 8px; font-size:.85rem; }
/* modal fix */
.modal-backdrop { position:fixed; inset:0; display:none; align-items:center; justify-content:center; background: rgba(10,12,20,.55); backdrop-filter: blur(4px); z-index: 1000; }
.modal { width:min(720px, 100%); max-height:92vh; overflow:auto; background: linear-gradient(180deg, #10121b 0%, #0d0f16 100%); border:1px solid #2a2b44; border-radius: 14px; }
.modal-header { display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid #20223a; background: rgba(13,15,22,.85); position:sticky; top:0; }
.modal-close { margin-left:auto; background:#1a1f33; color:#c9cbe3; border:1px solid #2a2b44; width:34px; height:34px; border-radius:10px; cursor:pointer; }
.section-block { background:#101327; border:1px solid #20223a; border-radius:12px; padding:14px; margin:12px; }
.section-title { font-weight:650; margin-bottom:8px; }
</style>
{% endblock %}
{% block content %}
<div class="wrap">
<h1>Subscriptions overview</h1>
<div class="filters">
<input type="text" id="q" placeholder="Search user/series/movies…">
</div>
<div class="section">
<h2>Users</h2>
<p class="muted">Tap a user to view all subscriptions.</p>
<div class="users" id="usersList">
{% for u in user_stats %}
<div class="user-item" data-user="{{ u.username_lower }}">
<div>{{ u.username }}</div>
<div class="badge">{{ u.total_count }}</div>
</div>
{% empty %}
<p class="muted">No subscriptions yet.</p>
{% endfor %}
</div>
</div>
<div class="section">
<h2>Series</h2>
<div class="grid" id="seriesGrid">
{% for s in series %}
<div class="card" data-user="{{ s.user.username|lower }}" data-title="{{ s.series_title|lower }}" data-id="{{ s.series_id }}">
<div class="poster">
{% if s.series_poster %}
<img src="{{ s.series_poster }}" alt="{{ s.series_title }}">
{% else %}
<img src="https://via.placeholder.com/90x135?text=No+Poster" alt="">
{% endif %}
</div>
<div class="meta">
<div class="title">{{ s.series_title }}</div>
<div class="muted">User: <span class="user">{{ s.user.username }}</span></div>
<div class="muted">SeriesId: {{ s.series_id }}</div>
<div class="muted">Since: {{ s.created_at }}</div>
</div>
</div>
{% empty %}
<p class="muted">No series subscriptions.</p>
{% endfor %}
</div>
</div>
<div class="section">
<h2>Movies</h2>
<div class="grid" id="moviesGrid">
{% for m in movies %}
<div class="card" data-user="{{ m.user.username|lower }}" data-title="{{ m.title|lower }}" data-id="{{ m.movie_id }}">
<div class="poster">
{% if m.poster %}
<img src="{{ m.poster }}" alt="{{ m.title }}">
{% else %}
<img src="https://via.placeholder.com/90x135?text=No+Poster" alt="">
{% endif %}
</div>
<div class="meta">
<div class="title">{{ m.title }}</div>
<div class="muted">User: <span class="user">{{ m.user.username }}</span></div>
<div class="muted">MovieId: {{ m.movie_id }}</div>
<div class="muted">Since: {{ m.created_at }}</div>
</div>
</div>
{% empty %}
<p class="muted">No movie subscriptions.</p>
{% endfor %}
</div>
</div>
</div>
<script>
(function(){
const q = document.getElementById('q');
function filter(){
const v = (q.value||'').toLowerCase();
[document.getElementById('seriesGrid'), document.getElementById('moviesGrid')].forEach(grid=>{
if(!grid) return; Array.from(grid.children).forEach(card=>{
const text = (card.dataset.user + ' ' + card.dataset.title).toLowerCase();
card.style.display = text.includes(v) ? '' : 'none';
});
});
const users = document.getElementById('usersList');
if(users){ Array.from(users.children).forEach(item=>{
const username = item.querySelector('div')?.textContent?.toLowerCase() || '';
item.style.display = username.includes(v) ? '' : 'none';
}); }
}
q.addEventListener('input', filter);
// Group-by-user popup
const modal = document.createElement('div');
modal.className = 'modal-backdrop';
modal.style.display = 'none';
modal.innerHTML = `
<div class="modal" style="max-width:720px">
<div class="modal-header">
<div style="font-weight:700" id="uTitle"></div>
<button class="modal-close" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="section-block"><div class="section-title">Series</div><div id="uSeries"></div></div>
<div class="section-block"><div class="section-title">Movies</div><div id="uMovies"></div></div>
</div>
</div>`;
document.body.appendChild(modal);
const closeBtn = modal.querySelector('.modal-close');
const uTitle = modal.querySelector('#uTitle');
const uSeries = modal.querySelector('#uSeries');
const uMovies = modal.querySelector('#uMovies');
function open(){ modal.style.display='flex'; document.body.style.overflow='hidden'; }
function close(){ modal.style.display='none'; document.body.style.overflow=''; }
closeBtn.addEventListener('click', close);
modal.addEventListener('click', e=>{ if(e.target===modal) close(); });
function renderList(container, items){
container.innerHTML = '';
if(!items.length){ container.innerHTML = '<div class="muted">—</div>'; return; }
items.forEach(it=>{
const row = document.createElement('div');
row.style.display = 'flex'; row.style.gap='10px'; row.style.alignItems='center'; row.style.margin='6px 0';
const poster = document.createElement('img');
poster.src = it.poster || 'https://via.placeholder.com/50x75?text=No+Poster';
poster.style.width='50px'; poster.style.height='75px'; poster.style.objectFit='cover'; poster.style.borderRadius='6px';
const meta = document.createElement('div');
meta.innerHTML = `<div style="font-weight:600">${it.title}</div><div class="muted">ID: ${it.id}</div>`;
row.appendChild(poster); row.appendChild(meta);
container.appendChild(row);
});
}
function gatherForUser(username){
const series = Array.from(document.querySelectorAll('#seriesGrid .card')).filter(c=>c.dataset.user===username).map(c=>{
const idFromAttr = c.dataset.id || '';
let idFromText = '';
if(!idFromAttr){
const text = Array.from(c.querySelectorAll('.muted')).map(el=>el.textContent).join(' ');
const m = text.match(/SeriesId:\s*(\d+)/i); idFromText = m ? m[1] : '';
}
return {
title: c.querySelector('.title')?.textContent || '',
id: idFromAttr || idFromText,
poster: c.querySelector('img')?.src || ''
};
});
const movies = Array.from(document.querySelectorAll('#moviesGrid .card')).filter(c=>c.dataset.user===username).map(c=>{
const idFromAttr = c.dataset.id || '';
let idFromText = '';
if(!idFromAttr){
const text = Array.from(c.querySelectorAll('.muted')).map(el=>el.textContent).join(' ');
const m = text.match(/MovieId:\s*(\d+)/i); idFromText = m ? m[1] : '';
}
return {
title: c.querySelector('.title')?.textContent || '',
id: idFromAttr || idFromText,
poster: c.querySelector('img')?.src || ''
};
});
return {series, movies};
}
function openForUser(usernameDisplay){
const username = usernameDisplay.trim().toLowerCase();
const {series, movies} = gatherForUser(username);
uTitle.textContent = `Subscriptions for ${usernameDisplay.trim()}`;
renderList(uSeries, series);
renderList(uMovies, movies);
open();
}
document.querySelectorAll('.user').forEach(uEl=>{
uEl.style.cursor='pointer';
uEl.title='Show all subscriptions for this user';
uEl.addEventListener('click', ()=> openForUser(uEl.textContent));
});
document.querySelectorAll('.user-item').forEach(item=>{
item.addEventListener('click', ()=>{
const name = item.querySelector('div')?.textContent || '';
openForUser(name);
});
});
})();
</script>
{% endblock %}

View File

@@ -1,9 +1,10 @@
from django.urls import path from django.urls import path
from .views import SettingsView, test_connection, first_run from .views import SettingsView, test_connection, first_run, subscriptions_overview
app_name = "settingspanel" app_name = "settingspanel"
urlpatterns = [ urlpatterns = [
path("", SettingsView.as_view(), name="index"), path("", SettingsView.as_view(), name="index"),
path("test-connection/", test_connection, name="test_connection"), path("test-connection/", test_connection, name="test_connection"),
path("setup/", first_run, name="setup"), path("setup/", first_run, name="setup"),
path("subscriptions/", subscriptions_overview, name="subscriptions"),
] ]

View File

@@ -7,6 +7,8 @@ from .models import AppSettings
from django.http import JsonResponse from django.http import JsonResponse
from accounts.utils import jellyfin_admin_required from accounts.utils import jellyfin_admin_required
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from arr_api.models import SeriesSubscription, MovieSubscription
from django.db.models import Count
import requests import requests
def needs_setup(): def needs_setup():
@@ -32,7 +34,7 @@ def first_run(request):
settings.radarr_api_key = form.cleaned_data['radarr_api_key'] settings.radarr_api_key = form.cleaned_data['radarr_api_key']
settings.save() settings.save()
messages.success(request, 'Setup erfolgreich abgeschlossen!') messages.success(request, 'Setup completed successfully!')
return redirect('accounts:login') return redirect('accounts:login')
else: else:
form = FirstRunSetupForm() form = FirstRunSetupForm()
@@ -53,9 +55,9 @@ def test_connection(request):
url = (request.GET.get("url") or "").strip() url = (request.GET.get("url") or "").strip()
key = (request.GET.get("key") or "").strip() key = (request.GET.get("key") or "").strip()
if kind not in ("sonarr", "radarr"): if kind not in ("sonarr", "radarr"):
return JsonResponse({"ok": False, "error": "Ungültiger Typ"}, status=400) return JsonResponse({"ok": False, "error": "Invalid type"}, status=400)
if not url or not key: if not url or not key:
return JsonResponse({"ok": False, "error": "URL und API-Key erforderlich"}, status=400) return JsonResponse({"ok": False, "error": "URL and API key required"}, status=400)
try: try:
r = requests.get( r = requests.get(
@@ -111,29 +113,81 @@ class SettingsView(View):
"jellyfin_form": jellyfin_form, "jellyfin_form": jellyfin_form,
"arr_form": arr_form, "arr_form": arr_form,
"mail_form": mail_form, "mail_form": mail_form,
"account_form": acc_form "account_form": acc_form,
}) })
cfg = AppSettings.current() cfg = AppSettings.current()
# Update Jellyfin settings # Update Jellyfin settings
cfg.jellyfin_server_url = jellyfin_form.cleaned_data["jellyfin_server_url"] or None cfg.jellyfin_server_url = jellyfin_form.cleaned_data.get("jellyfin_server_url") or None
cfg.jellyfin_api_key = jellyfin_form.cleaned_data["jellyfin_api_key"] or None cfg.jellyfin_api_key = jellyfin_form.cleaned_data.get("jellyfin_api_key") or None
cfg.sonarr_url = arr_form.cleaned_data["sonarr_url"] or None
cfg.sonarr_api_key = arr_form.cleaned_data["sonarr_api_key"] or None
cfg.radarr_url = arr_form.cleaned_data["radarr_url"] or None
cfg.radarr_api_key = arr_form.cleaned_data["radarr_api_key"] or None
cfg.mail_host = mail_form.cleaned_data["mail_host"] or None # Update Sonarr/Radarr settings
cfg.mail_port = mail_form.cleaned_data["mail_port"] or None cfg.sonarr_url = arr_form.cleaned_data.get("sonarr_url") or None
cfg.mail_secure = mail_form.cleaned_data["mail_secure"] or "" cfg.sonarr_api_key = arr_form.cleaned_data.get("sonarr_api_key") or None
cfg.mail_user = mail_form.cleaned_data["mail_user"] or None cfg.radarr_url = arr_form.cleaned_data.get("radarr_url") or None
cfg.mail_password = mail_form.cleaned_data["mail_password"] or None cfg.radarr_api_key = arr_form.cleaned_data.get("radarr_api_key") or None
cfg.mail_from = mail_form.cleaned_data["mail_from"] or None
cfg.acc_username = acc_form.cleaned_data["username"] or None # Update Mail settings
cfg.acc_email = acc_form.cleaned_data["email"] or None cfg.mail_host = mail_form.cleaned_data.get("mail_host") or None
cfg.mail_port = mail_form.cleaned_data.get("mail_port") or None
cfg.mail_secure = mail_form.cleaned_data.get("mail_secure") or ""
cfg.mail_user = mail_form.cleaned_data.get("mail_user") or None
cfg.mail_password = mail_form.cleaned_data.get("mail_password") or None
cfg.mail_from = mail_form.cleaned_data.get("mail_from") or None
# Update account settings
cfg.acc_username = acc_form.cleaned_data.get("username") or None
cfg.acc_email = acc_form.cleaned_data.get("email") or None
cfg.save() cfg.save()
messages.success(request, "Einstellungen gespeichert (DB).") messages.success(request, "Settings saved (DB).")
return redirect("settingspanel:index") return redirect("settingspanel:index")
@jellyfin_admin_required
def subscriptions_overview(request):
series = SeriesSubscription.objects.select_related('user').order_by('user__username', 'series_title')
movies = MovieSubscription.objects.select_related('user').order_by('user__username', 'title')
# Aggregate counts per user
s_counts = SeriesSubscription.objects.values('user_id', 'user__username').annotate(series_count=Count('id'))
m_counts = MovieSubscription.objects.values('user_id', 'user__username').annotate(movie_count=Count('id'))
user_map = {}
for row in s_counts:
key = row['user_id']
user_map.setdefault(key, {
'user_id': key,
'username': row['user__username'],
'series_count': 0,
'movie_count': 0,
})
user_map[key]['series_count'] = row['series_count']
for row in m_counts:
key = row['user_id']
user_map.setdefault(key, {
'user_id': key,
'username': row['user__username'],
'series_count': 0,
'movie_count': 0,
})
user_map[key]['movie_count'] = row['movie_count']
user_stats = []
for key, val in user_map.items():
total = (val.get('series_count') or 0) + (val.get('movie_count') or 0)
user_stats.append({
'user_id': val['user_id'],
'username': val['username'],
'username_lower': (val['username'] or '').lower(),
'series_count': val.get('series_count') or 0,
'movie_count': val.get('movie_count') or 0,
'total_count': total,
})
user_stats.sort(key=lambda x: (-x['total_count'], x['username'].lower()))
return render(request, 'settingspanel/subscriptions.html', {
'series': series,
'movies': movies,
'user_stats': user_stats,
})

View File

@@ -120,6 +120,27 @@ body {
align-items: center; align-items: center;
} }
.nav-toggle {
display: none;
background: transparent;
border: 1px solid var(--panel-b);
color: var(--text);
padding: 8px;
border-radius: 8px;
cursor: pointer;
}
.nav-toggle span { display:block; width:20px; height:2px; background: var(--text); margin:4px 0; }
@media (max-width: 768px) {
.nav-content { flex-wrap: wrap; gap: 10px; }
.nav-toggle { display: inline-block; }
.nav-links { display: none; width: 100%; flex-direction: column; align-items: stretch; gap: 6px; }
.nav-links.open { display: flex; }
.nav-links a, .nav-links button, .nav-links .inline-form { width: 100%; }
.nav-links a, .nav-links button { justify-content: flex-start; padding: 10px 12px; border: 1px solid var(--panel-b); border-radius: 10px; }
.user-info { order: -1; margin: 0 0 4px 0; }
}
.nav-links a, .nav-links a,
.nav-links button { .nav-links button {
display: flex; display: flex;
@@ -151,9 +172,9 @@ body {
background: var(--accent); background: var(--accent);
} }
.nav-logout { .nav-logout { color: #ef4444 !important; }
color: #ef4444 !important; .btn-link { background: transparent; border: 1px solid var(--panel-b); border-radius: 8px; }
} .btn-link:hover { background: rgba(255,255,255,0.06); }
.nav-admin { .nav-admin {
color: var(--accent) !important; color: var(--accent) !important;

View File

@@ -39,6 +39,14 @@ h1 {
margin-bottom: 16px margin-bottom: 16px
} }
@media (max-width: 640px) {
.controls { flex-direction: column; align-items: stretch; }
.controls form { width: 100%; }
.controls .seg { width: 100%; justify-content: space-between; }
.controls-actions { width: 100%; }
.controls-actions .btn { width: 100%; justify-content: center; }
}
.controls form { .controls form {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -67,6 +75,24 @@ h1 {
cursor: pointer cursor: pointer
} }
/* Calendar button */
.controls-actions .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #2a2b44;
background: linear-gradient(180deg, #19213b 0%, #141a30 100%);
color: #e6e6e6;
text-decoration: none;
font-weight: 650;
box-shadow: 0 6px 18px rgba(0,0,0,.25);
transition: transform .08s ease, filter .12s ease, border-color .12s ease;
}
.controls-actions .btn:hover { filter: brightness(1.08); border-color: #3b4aa0; transform: translateY(-1px); }
.controls-actions .btn:active { transform: translateY(0); }
.seg { .seg {
display: inline-flex; display: inline-flex;
background: #0f0f17; background: #0f0f17;
@@ -99,6 +125,10 @@ h1 {
} }
} }
@media (max-width: 640px) {
.grid { grid-template-columns: 1fr; gap: 12px; }
}
.card { .card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--panel-b); border: 1px solid var(--panel-b);
@@ -111,6 +141,12 @@ h1 {
cursor: pointer cursor: pointer
} }
@media (max-width: 640px) {
.card { gap: 10px; }
.poster { width: 90px; height: 135px; }
.title { font-size: .98rem; }
}
.card:active, .card:active,
.card:hover { .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -260,6 +296,12 @@ h1 {
border-bottom: 1px solid #20223a border-bottom: 1px solid #20223a
} }
@media (max-width: 640px) {
.modal-header { grid-template-columns: 90px 1fr 36px; gap: 10px; }
.m-poster { width: 90px; height: 135px; }
.m-title { font-size: 1.05rem; }
}
.m-poster { .m-poster {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -344,6 +386,10 @@ h1 {
} }
} }
@media (max-width: 640px) {
.modal-body { grid-template-columns: 1fr; }
}
.section-block { .section-block {
background: #101327; background: #101327;
border: 1px solid #20223a; border: 1px solid #20223a;

View File

@@ -1,6 +1,6 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -14,15 +14,18 @@
<nav class="main-nav"> <nav class="main-nav">
<div class="nav-content"> <div class="nav-content">
<a href="/" class="nav-brand">Subscribarr</a> <a href="/" class="nav-brand">Subscribarr</a>
<div class="nav-links"> <button class="nav-toggle" aria-label="Open menu" aria-expanded="false" aria-controls="nav-menu">
<span></span><span></span><span></span>
</button>
<div id="nav-menu" class="nav-links">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<span class="user-info">Angemeldet als <strong>{{ user.username }}</strong></span> <span class="user-info">Signed in as <strong>{{ user.username }}</strong></span>
<a href="{% url 'accounts:profile' %}" class="nav-profile"> <a href="{% url 'accounts:profile' %}" class="nav-profile">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle> <circle cx="12" cy="7" r="4"></circle>
</svg> </svg>
Profil Profile
</a> </a>
{% if user.is_jellyfin_admin %} {% if user.is_jellyfin_admin %}
<a href="{% url 'settingspanel:index' %}" class="nav-admin"> <a href="{% url 'settingspanel:index' %}" class="nav-admin">
@@ -34,14 +37,14 @@
{% endif %} {% endif %}
<form method="post" action="{% url 'accounts:logout' %}" class="inline-form"> <form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="nav-logout"> <button type="submit" class="nav-logout btn-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2"> stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline> <polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line> <line x1="21" y1="12" x2="9" y2="12"></line>
</svg> </svg>
Abmelden Sign out
</button> </button>
</form> </form>
{% else %} {% else %}
@@ -51,7 +54,7 @@
<polyline points="10 17 15 12 10 7"></polyline> <polyline points="10 17 15 12 10 7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line> <line x1="15" y1="12" x2="3" y2="12"></line>
</svg> </svg>
Anmelden Sign in
</a> </a>
<a href="{% url 'accounts:register' %}" class="nav-register"> <a href="{% url 'accounts:register' %}" class="nav-register">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -60,13 +63,25 @@
<line x1="20" y1="8" x2="20" y2="14"></line> <line x1="20" y1="8" x2="20" y2="14"></line>
<line x1="23" y1="11" x2="17" y2="11"></line> <line x1="23" y1="11" x2="17" y2="11"></line>
</svg> </svg>
Registrieren Register
</a> </a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</nav> </nav>
<script>
(function(){
const btn = document.querySelector('.nav-toggle');
const menu = document.getElementById('nav-menu');
if(!btn || !menu) return;
btn.addEventListener('click', ()=>{
const open = menu.classList.toggle('open');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
})();
</script>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</body> </body>