Compare commits

..

9 Commits

Author SHA1 Message Date
Yiannis Christodoulou
0ddcb0be1c build assets 2025-11-09 22:42:21 +02:00
Yiannis Christodoulou
5190fb4474 fix: First chapter is not saved, if not given a name, although indicating it is saved (151) 2025-11-09 22:42:14 +02:00
Yiannis Christodoulou
846d9fa3f6 build assets 2025-11-09 22:22:10 +02:00
Yiannis Christodoulou
b317f406c3 fix: Scroll is locked: Need to reload page. (149)
Introduced lockBodyScroll and unlockBodyScroll methods in CustomChaptersOverlay and CustomSettingsMenu to consistently handle body scroll locking and restoration on mobile devices when overlays are opened or closed. This improves iOS compatibility and code maintainability by centralizing scroll management logic.
2025-11-09 22:20:15 +02:00
Yiannis Christodoulou
9a8a34317d Fix audio poster display for Safari in video players (144)
Adds persistent background poster images for audio files in both VideoPlayer and IOSVideoPlayer components to address Safari rendering issues. Updates CSS and component logic to ensure poster images remain visible for audio files, improving user experience and visual consistency.
2025-11-09 21:54:18 +02:00
Markos Gogoulos
3abc012de1 next 2025-11-04 15:38:35 +02:00
Yiannis Christodoulou
fef262496d fix: Show autoplay button everywhere, Remove fake related items, Trim audio on Safari (#1424) 2025-11-04 09:38:39 +02:00
Markos Gogoulos
3e79f5a558 fixes 2025-10-29 15:25:08 +02:00
Markos Gogoulos
a320375e16 Bulk actions support
3wtv
2025-10-28 15:24:29 +02:00
37 changed files with 110 additions and 78 deletions

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pycqa/flake8
rev: 6.1.0
rev: 6.0.0
hooks:
- id: flake8
- repo: https://github.com/pycqa/isort

View File

@@ -1 +1 @@
VERSION = "7.2.0"
VERSION = "7.1.0"

View File

@@ -272,16 +272,12 @@ def show_related_media_content(media, request, limit):
category = media.category.first()
if category:
q_category = Q(listable=True, category=category)
# Fix: Ensure slice index is never negative
remaining = max(0, limit - len(m))
q_res = models.Media.objects.filter(q_category).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[:remaining]
q_res = models.Media.objects.filter(q_category).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[: limit - media.user.media_count]
m = list(itertools.chain(m, q_res))
if len(m) < limit:
q_generic = Q(listable=True)
# Fix: Ensure slice index is never negative
remaining = max(0, limit - len(m))
q_res = models.Media.objects.filter(q_generic).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[:remaining]
q_res = models.Media.objects.filter(q_generic).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[: limit - media.user.media_count]
m = list(itertools.chain(m, q_res))
m = list(set(m[:limit])) # remove duplicates
@@ -670,8 +666,11 @@ def change_media_owner(media_id, new_user):
media.user = new_user
media.save(update_fields=["user"])
# Optimize: Update any related permissions in bulk instead of loop
models.MediaPermission.objects.filter(media=media).update(owner_user=new_user)
# Update any related permissions
media_permissions = models.MediaPermission.objects.filter(media=media)
for permission in media_permissions:
permission.owner_user = new_user
permission.save(update_fields=["owner_user"])
# remove any existing permissions for the new user, since they are now owner
models.MediaPermission.objects.filter(media=media, user=new_user).delete()

View File

@@ -91,10 +91,10 @@ class Category(models.Model):
if self.listings_thumbnail:
return self.listings_thumbnail
# Optimize: Use first() directly instead of exists() + first() (saves one query)
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
if media:
return media.thumbnail_url
if Media.objects.filter(category=self, state="public").exists():
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
if media:
return media.thumbnail_url
return None

View File

@@ -74,8 +74,10 @@ class MediaList(APIView):
if not request.user.is_authenticated:
return base_queryset.filter(base_filters)
conditions = base_filters
# Build OR conditions for authenticated users
conditions = base_filters # Start with listable media
# Add user permissions
permission_filter = {'user': request.user}
if user:
permission_filter['owner_user'] = user
@@ -86,6 +88,7 @@ class MediaList(APIView):
perm_conditions &= Q(user=user)
conditions |= perm_conditions
# Add RBAC conditions
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
rbac_conditions = Q(category__in=rbac_categories)
@@ -96,6 +99,7 @@ class MediaList(APIView):
return base_queryset.filter(conditions).distinct()
def get(self, request, format=None):
# Show media
# authenticated users can see:
# All listable media (public access)
@@ -114,6 +118,7 @@ class MediaList(APIView):
publish_state = params.get('publish_state', '').strip()
query = params.get("q", "").strip().lower()
# Handle combined sort options (e.g., title_asc, views_desc)
parsed_combined = False
if sort_by and '_' in sort_by:
parts = sort_by.rsplit('_', 1)
@@ -232,14 +237,14 @@ class MediaList(APIView):
if not already_sorted:
media = media.order_by(f"{ordering}{sort_by}")
media = media[:1000]
media = media[:1000] # limit to 1000 results
paginator = pagination_class()
page = paginator.paginate_queryset(media, request)
serializer = MediaSerializer(page, many=True, context={"request": request})
# Collect all unique tags from the current page results
tags_set = set()
for media_obj in page:
for tag in media_obj.tags.all():
@@ -349,23 +354,28 @@ class MediaBulkUserActions(APIView):
},
)
def post(self, request, format=None):
# Check if user is authenticated
if not request.user.is_authenticated:
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
# Get required parameters
media_ids = request.data.get('media_ids', [])
action = request.data.get('action')
# Validate required parameters
if not media_ids:
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
if not action:
return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST)
# Get media objects owned by the user
media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
if not media:
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
# Process based on action
if action == "enable_comments":
media.update(enable_comments=True)
return Response({"detail": f"Comments enabled for {media.count()} media items"})
@@ -436,10 +446,12 @@ class MediaBulkUserActions(APIView):
if state not in valid_states:
return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST)
# Check if user can set public state
if not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public":
if state == "public":
return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST)
# Update media state
for m in media:
m.state = state
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True:
@@ -483,6 +495,8 @@ class MediaBulkUserActions(APIView):
if ownership_type not in valid_ownership_types:
return Response({"detail": f"ownership_type must be one of {valid_ownership_types}"}, status=status.HTTP_400_BAD_REQUEST)
# Find users who have the permission on ALL media items (intersection)
media_count = media.count()
users = (
@@ -509,6 +523,7 @@ class MediaBulkUserActions(APIView):
if not usernames:
return Response({"detail": "users is required for set_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
# Get valid users from the provided usernames
users = User.objects.filter(username__in=usernames)
if not users.exists():
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
@@ -533,17 +548,22 @@ class MediaBulkUserActions(APIView):
if not usernames:
return Response({"detail": "users is required for remove_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
# Get valid users from the provided usernames
users = User.objects.filter(username__in=usernames)
if not users.exists():
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
# Delete MediaPermission objects matching the criteria
MediaPermission.objects.filter(media__in=media, permission=ownership_type, user__in=users).delete()
return Response({"detail": "Action succeeded"})
elif action == "playlist_membership":
# Find playlists that contain ALL the selected media (intersection)
media_count = media.count()
# Query playlists owned by user that contain these media
results = list(
Playlist.objects.filter(user=request.user, playlistmedia__media__in=media)
.values('id', 'friendly_token', 'title')
@@ -554,15 +574,21 @@ class MediaBulkUserActions(APIView):
return Response({'results': results})
elif action == "category_membership":
# Find categories that contain ALL the selected media (intersection)
media_count = media.count()
# Query categories that contain these media
results = list(Category.objects.filter(media__in=media).values('title', 'uid').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
return Response({'results': results})
elif action == "tag_membership":
# Find tags that contain ALL the selected media (intersection)
media_count = media.count()
# Query tags that contain these media
results = list(Tag.objects.filter(media__in=media).values('title').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
return Response({'results': results})
@@ -579,6 +605,7 @@ class MediaBulkUserActions(APIView):
added_count = 0
for category in categories:
for m in media:
# Add media to category (ManyToMany relationship)
if not m.category.filter(uid=category.uid).exists():
m.category.add(category)
added_count += 1
@@ -597,6 +624,7 @@ class MediaBulkUserActions(APIView):
removed_count = 0
for category in categories:
for m in media:
# Remove media from category (ManyToMany relationship)
if m.category.filter(uid=category.uid).exists():
m.category.remove(category)
removed_count += 1
@@ -615,6 +643,7 @@ class MediaBulkUserActions(APIView):
added_count = 0
for tag in tags:
for m in media:
# Add media to tag (ManyToMany relationship)
if not m.tags.filter(title=tag.title).exists():
m.tags.add(tag)
added_count += 1
@@ -633,6 +662,7 @@ class MediaBulkUserActions(APIView):
removed_count = 0
for tag in tags:
for m in media:
# Remove media from tag (ManyToMany relationship)
if m.tags.filter(title=tag.title).exists():
m.tags.remove(tag)
removed_count += 1

View File

@@ -26,12 +26,17 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
csrfToken,
}) => {
const [selectedState, setSelectedState] = useState('public');
const [initialState, setInitialState] = useState('public');
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setSelectedState('public');
setInitialState('public');
} else {
// When modal opens, set initial state
setInitialState('public');
}
}, [isOpen]);
@@ -74,9 +79,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
if (!isOpen) return null;
// Note: We don't check hasStateChanged because the modal doesn't know the actual
// current state of the selected media. Users should be able to set any state.
// If the state is already the same, the backend will handle it gracefully.
const hasStateChanged = selectedState !== initialState;
return (
<div className="publish-state-modal-overlay">
@@ -113,7 +116,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
<button
className="publish-state-btn publish-state-btn-submit"
onClick={handleSubmit}
disabled={isProcessing}
disabled={isProcessing || !hasStateChanged}
>
{isProcessing ? translateString('Processing...') : translateString('Submit')}
</button>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long