replace media, shared state, better category options

This commit is contained in:
Markos Gogoulos
2025-12-24 12:14:01 +02:00
committed by GitHub
parent 872571350f
commit fa67ffffb4
46 changed files with 383 additions and 35 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ media_files/hls/
media_files/chunks/
media_files/uploads/
media_files/tinymce_media/
media_files/userlogos/
postgres_data/
celerybeat-schedule
logs/

View File

@@ -563,7 +563,8 @@ ALLOW_VIDEO_TRIMMER = True
ALLOW_CUSTOM_MEDIA_URLS = False
# Whether to allow anonymous users to list all users
ALLOW_MEDIA_REPLACEMENT = False
ALLOW_ANONYMOUS_USER_LISTING = True
# Who can see the members page

View File

@@ -1 +1 @@
VERSION = "7.3"
VERSION = "7.4"

View File

@@ -58,6 +58,7 @@ def stuff(request):
ret["USE_RBAC"] = settings.USE_RBAC
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS
ret["ALLOW_MEDIA_REPLACEMENT"] = getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False)
ret["VERSION"] = VERSION
if request.user.is_superuser:

View File

@@ -6,6 +6,7 @@ from django.conf import settings
from .methods import get_next_state, is_mediacms_editor
from .models import MEDIA_STATES, Category, Media, Subtitle
from .widgets import CategoryModalWidget
class CustomField(Field):
@@ -121,13 +122,18 @@ class MediaPublishForm(forms.ModelForm):
fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download")
widgets = {
"category": MultipleSelect(),
"category": CategoryModalWidget(),
}
def __init__(self, user, *args, **kwargs):
self.user = user
super(MediaPublishForm, self).__init__(*args, **kwargs)
self.has_custom_permissions = self.instance.permissions.exists() if self.instance.pk else False
self.has_rbac_categories = self.instance.category.filter(is_rbac_category=True).exists() if self.instance.pk else False
self.is_shared = self.has_custom_permissions or self.has_rbac_categories
self.actual_state = self.instance.state if self.instance.pk else None
if not is_mediacms_editor(user):
for field in ["featured", "reported_times", "is_reviewed"]:
self.fields[field].disabled = True
@@ -140,6 +146,13 @@ class MediaPublishForm(forms.ModelForm):
valid_states.append(self.instance.state)
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
if self.is_shared:
current_choices = list(self.fields["state"].choices)
current_choices.insert(0, ("shared", "Shared"))
self.fields["state"].choices = current_choices
self.fields["state"].initial = "shared"
self.initial["state"] = "shared"
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
if is_mediacms_editor(user):
pass
@@ -178,7 +191,35 @@ class MediaPublishForm(forms.ModelForm):
state = cleaned_data.get("state")
categories = cleaned_data.get("category")
if state in ['private', 'unlisted']:
if self.is_shared and state != "shared":
self.fields['confirm_state'].widget = forms.CheckboxInput()
state_index = None
for i, layout_item in enumerate(self.helper.layout):
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
state_index = i
break
if state_index is not None:
layout_items = list(self.helper.layout)
layout_items.insert(state_index + 1, CustomField('confirm_state'))
self.helper.layout = Layout(*layout_items)
if not cleaned_data.get('confirm_state'):
if state == 'private':
error_parts = []
if self.has_rbac_categories:
rbac_cat_titles = self.instance.category.filter(is_rbac_category=True).values_list('title', flat=True)
error_parts.append(f"shared with users that have access to categories: {', '.join(rbac_cat_titles)}")
if self.has_custom_permissions:
error_parts.append("shared by me with other users (visible in 'Shared by me' page)")
error_message = f"I understand that changing to Private will remove all sharing. Currently this media is {' and '.join(error_parts)}. All this sharing will be removed."
self.add_error('confirm_state', error_message)
else:
error_message = f"I understand that changing to {state.title()} will maintain existing sharing settings."
self.add_error('confirm_state', error_message)
elif state in ['private', 'unlisted']:
custom_permissions = self.instance.permissions.exists()
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
if rbac_categories or custom_permissions:
@@ -189,7 +230,7 @@ class MediaPublishForm(forms.ModelForm):
state_index = i
break
if state_index:
if state_index is not None:
layout_items = list(self.helper.layout)
layout_items.insert(state_index + 1, CustomField('confirm_state'))
self.helper.layout = Layout(*layout_items)
@@ -202,11 +243,24 @@ class MediaPublishForm(forms.ModelForm):
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
self.add_error('confirm_state', error_message)
# Convert "shared" state to actual underlying state for saving. we dont keep shared state in DB
if state == "shared":
cleaned_data["state"] = self.actual_state
return cleaned_data
def save(self, *args, **kwargs):
data = self.cleaned_data
state = data.get("state")
# If transitioning from shared to private, remove all sharing
if self.is_shared and state == 'private' and data.get('confirm_state'):
# Remove all custom permissions
self.instance.permissions.all().delete()
# Remove RBAC categories
rbac_cats = self.instance.category.filter(is_rbac_category=True)
self.instance.category.remove(*rbac_cats)
if state != self.initial["state"]:
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
@@ -333,3 +387,35 @@ class ContactForm(forms.Form):
if user.is_authenticated:
self.fields.pop("name")
self.fields.pop("from_email")
class ReplaceMediaForm(forms.Form):
new_media_file = forms.FileField(
required=True,
label="New Media File",
help_text="Select a new file to replace the current media",
)
def __init__(self, media_instance, *args, **kwargs):
self.media_instance = media_instance
super(ReplaceMediaForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = True
self.helper.form_class = 'post-form'
self.helper.form_method = 'post'
self.helper.form_enctype = "multipart/form-data"
self.helper.form_show_errors = False
self.helper.layout = Layout(
CustomField('new_media_file'),
)
self.helper.layout.append(FormActions(Submit('submit', 'Replace Media', css_class='primaryAction')))
def clean_new_media_file(self):
file = self.cleaned_data.get("new_media_file", False)
if file:
if file.size > settings.UPLOAD_MAX_SIZE:
max_size_mb = settings.UPLOAD_MAX_SIZE / (1024 * 1024)
raise forms.ValidationError(f"File too large. Maximum size: {max_size_mb:.0f}MB")
return file

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "إزالة من القائمة",
"Remove tag": "إزالة العلامة",
"Remove user": "إزالة المستخدم",
"Replace": "",
"SAVE": "حفظ",
"SEARCH": "بحث",
"SHARE": "مشاركة",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "",
"Remove tag": "",
"Remove user": "",
"Replace": "",
"SAVE": "সংরক্ষণ করুন",
"SEARCH": "অনুসন্ধান",
"SHARE": "শেয়ার করুন",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Fjern fra liste",
"Remove tag": "Fjern tag",
"Remove user": "Fjern bruger",
"Replace": "",
"SAVE": "GEM",
"SEARCH": "SØG",
"SHARE": "DEL",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Aus Liste entfernen",
"Remove tag": "Tag entfernen",
"Remove user": "Benutzer entfernen",
"Replace": "",
"SAVE": "SPEICHERN",
"SEARCH": "SUCHE",
"SHARE": "TEILEN",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Αφαίρεση από λίστα",
"Remove tag": "Αφαίρεση ετικέτας",
"Remove user": "Αφαίρεση χρήστη",
"Replace": "",
"SAVE": "ΑΠΟΘΗΚΕΥΣΗ",
"SEARCH": "ΑΝΑΖΗΤΗΣΗ",
"SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ",

View File

@@ -165,6 +165,7 @@ translation_strings = {
"Recommended": "",
"Record Screen": "",
"Register": "",
"Replace": "",
"Remove category": "",
"Remove from list": "",
"Remove tag": "",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Eliminar de la lista",
"Remove tag": "Eliminar etiqueta",
"Remove user": "Eliminar usuario",
"Replace": "",
"SAVE": "GUARDAR",
"SEARCH": "BUSCAR",
"SHARE": "COMPARTIR",

View File

@@ -163,6 +163,7 @@ translation_strings = {
"Remove from list": "Supprimer de la liste",
"Remove tag": "Supprimer le tag",
"Remove user": "Supprimer l'utilisateur",
"Replace": "",
"SAVE": "ENREGISTRER",
"SEARCH": "RECHERCHER",
"SHARE": "PARTAGER",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "",
"Remove tag": "",
"Remove user": "",
"Replace": "",
"SAVE": "שמור",
"SEARCH": "חפש",
"SHARE": "שתף",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "सूची से हटाएं",
"Remove tag": "टैग हटाएं",
"Remove user": "उपयोगकर्ता हटाएं",
"Replace": "",
"SAVE": "सहेजें",
"SEARCH": "खोजें",
"SHARE": "साझा करें",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Hapus dari daftar",
"Remove tag": "Hapus tag",
"Remove user": "Hapus pengguna",
"Replace": "",
"SAVE": "SIMPAN",
"SEARCH": "CARI",
"SHARE": "BAGIKAN",

View File

@@ -163,6 +163,7 @@ translation_strings = {
"Remove from list": "Rimuovi dalla lista",
"Remove tag": "Rimuovi tag",
"Remove user": "Rimuovi utente",
"Replace": "",
"SAVE": "SALVA",
"SEARCH": "CERCA",
"SHARE": "CONDIVIDI",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "リストから削除",
"Remove tag": "タグを削除",
"Remove user": "ユーザーを削除",
"Replace": "",
"SAVE": "保存",
"SEARCH": "検索",
"SHARE": "共有",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "목록에서 제거",
"Remove tag": "태그 제거",
"Remove user": "사용자 제거",
"Replace": "",
"SAVE": "저장",
"SEARCH": "검색",
"SHARE": "공유",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Verwijderen uit lijst",
"Remove tag": "Tag verwijderen",
"Remove user": "Gebruiker verwijderen",
"Replace": "",
"SAVE": "OPSLAAN",
"SEARCH": "ZOEKEN",
"SHARE": "DELEN",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Remover da lista",
"Remove tag": "Remover tag",
"Remove user": "Remover usuário",
"Replace": "",
"SAVE": "SALVAR",
"SEARCH": "PESQUISAR",
"SHARE": "COMPARTILHAR",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Удалить из списка",
"Remove tag": "Удалить тег",
"Remove user": "Удалить пользователя",
"Replace": "",
"SAVE": "СОХРАНИТЬ",
"SEARCH": "ПОИСК",
"SHARE": "ПОДЕЛИТЬСЯ",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Odstrani s seznama",
"Remove tag": "Odstrani oznako",
"Remove user": "Odstrani uporabnika",
"Replace": "",
"SAVE": "SHRANI",
"SEARCH": "ISKANJE",
"SHARE": "DELI",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Listeden kaldır",
"Remove tag": "Etiketi kaldır",
"Remove user": "Kullanıcıyı kaldır",
"Replace": "",
"SAVE": "KAYDET",
"SEARCH": "ARA",
"SHARE": "PAYLAŞ",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "فہرست سے ہٹائیں",
"Remove tag": "ٹیگ ہٹائیں",
"Remove user": "صارف ہٹائیں",
"Replace": "",
"SAVE": "محفوظ کریں",
"SEARCH": "تلاش کریں",
"SHARE": "شیئر کریں",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "",
"Remove tag": "",
"Remove user": "",
"Replace": "",
"SAVE": "保存",
"SEARCH": "搜索",
"SHARE": "分享",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "",
"Remove tag": "",
"Remove user": "",
"Replace": "",
"SAVE": "儲存",
"SEARCH": "搜尋",
"SHARE": "分享",

View File

@@ -270,7 +270,9 @@ class Media(models.Model):
if self.media_file != self.__original_media_file:
# set this otherwise gets to infinite loop
self.__original_media_file = self.media_file
self.media_init()
from .. import tasks
tasks.media_init.apply_async(args=[self.friendly_token], countdown=5)
# for video files, if user specified a different time
# to automatically grub thumbnail
@@ -417,6 +419,11 @@ class Media(models.Model):
self.media_type = "image"
elif kind == "pdf":
self.media_type = "pdf"
elif kind == "audio":
self.media_type = "audio"
elif kind == "video":
self.media_type = "video"
if self.media_type in ["image", "pdf"]:
self.encoding_status = "success"
else:

View File

@@ -101,10 +101,17 @@ class MediaSerializer(serializers.ModelSerializer):
class SingleMediaSerializer(serializers.ModelSerializer):
user = serializers.ReadOnlyField(source="user.username")
url = serializers.SerializerMethodField()
is_shared = serializers.SerializerMethodField()
def get_url(self, obj):
return self.context["request"].build_absolute_uri(obj.get_absolute_url())
def get_is_shared(self, obj):
"""Check if media has custom permissions or RBAC categories"""
custom_permissions = obj.permissions.exists()
rbac_categories = obj.category.filter(is_rbac_category=True).exists()
return custom_permissions or rbac_categories
class Meta:
model = Media
read_only_fields = (
@@ -133,6 +140,7 @@ class SingleMediaSerializer(serializers.ModelSerializer):
"edit_date",
"media_type",
"state",
"is_shared",
"duration",
"thumbnail_url",
"poster_url",

View File

@@ -625,6 +625,18 @@ def create_hls(friendly_token):
return True
@task(name="media_init", queue="short_tasks")
def media_init(friendly_token):
try:
media = Media.objects.get(friendly_token=friendly_token)
except: # noqa
logger.info("failed to get media with friendly_token %s" % friendly_token)
return False
media.media_init()
return True
@task(name="check_running_states", queue="short_tasks")
def check_running_states():
# Experimental - unused

View File

@@ -20,6 +20,7 @@ urlpatterns = [
re_path(r"^contact$", views.contact, name="contact"),
re_path(r"^publish", views.publish_media, name="publish_media"),
re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"),
re_path(r"^replace_media", views.replace_media, name="replace_media"),
re_path(r"^edit_video", views.edit_video, name="edit_video"),
re_path(r"^edit", views.edit_media, name="edit_media"),
re_path(r"^embed", views.embed_media, name="get_embed"),

View File

@@ -32,6 +32,7 @@ from .pages import members # noqa: F401
from .pages import publish_media # noqa: F401
from .pages import recommended_media # noqa: F401
from .pages import record_screen # noqa: F401
from .pages import replace_media # noqa: F401
from .pages import search # noqa: F401
from .pages import setlanguage # noqa: F401
from .pages import sitemap # noqa: F401

View File

@@ -226,8 +226,13 @@ class MediaList(APIView):
elif duration == '60-120':
media = media.filter(duration__gte=3600)
if publish_state and publish_state in ['private', 'public', 'unlisted']:
media = media.filter(state=publish_state)
if publish_state:
if publish_state == 'shared':
# Filter media that have custom permissions OR RBAC categories
shared_conditions = Q(permissions__isnull=False) | Q(category__is_rbac_category=True)
media = media.filter(shared_conditions).distinct()
elif publish_state in ['private', 'public', 'unlisted']:
media = media.filter(state=publish_state)
if not already_sorted:
media = media.order_by(f"{ordering}{sort_by}")
@@ -799,13 +804,14 @@ class MediaDetail(APIView):
serializer = MediaSerializer(media, data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user)
# no need to update the media file itself, only the metadata
# if request.data.get('media_file'):
# media_file = request.data["media_file"]
# serializer.save(user=request.user, media_file=media_file)
# media_file = request.data["media_file"]
# media.state = helpers.get_default_state(request.user)
# media.listable = False
# serializer.save(user=request.user, media_file=media_file)
# else:
# serializer.save(user=request.user)
# serializer.save(user=request.user)
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -1,4 +1,5 @@
import json
import os
from django.conf import settings
from django.contrib import messages
@@ -18,6 +19,7 @@ from ..forms import (
EditSubtitleForm,
MediaMetadataForm,
MediaPublishForm,
ReplaceMediaForm,
SubtitleForm,
WhisperSubtitlesForm,
)
@@ -363,6 +365,76 @@ def publish_media(request):
)
@login_required
def replace_media(request):
"""Replace media file"""
if not getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False):
return HttpResponseRedirect("/")
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if not is_media_allowed_type(media):
return HttpResponseRedirect(media.get_absolute_url())
if request.method == "POST":
form = ReplaceMediaForm(media, request.POST, request.FILES)
if form.is_valid():
new_media_file = form.cleaned_data.get("new_media_file")
media.encodings.all().delete()
if media.thumbnail:
helpers.rm_file(media.thumbnail.path)
media.thumbnail = None
if media.poster:
helpers.rm_file(media.poster.path)
media.poster = None
if media.uploaded_thumbnail:
helpers.rm_file(media.uploaded_thumbnail.path)
media.uploaded_thumbnail = None
if media.uploaded_poster:
helpers.rm_file(media.uploaded_poster.path)
media.uploaded_poster = None
if media.sprites:
helpers.rm_file(media.sprites.path)
media.sprites = None
if media.preview_file_path:
helpers.rm_file(media.preview_file_path)
media.preview_file_path = ""
if media.hls_file:
hls_dir = os.path.dirname(media.hls_file)
helpers.rm_dir(hls_dir)
media.hls_file = ""
media.media_file = new_media_file
media.listable = False
media.state = helpers.get_default_state(request.user)
media.save()
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media file was replaced successfully"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = ReplaceMediaForm(media)
return render(
request,
"cms/replace_media.html",
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
)
@login_required
def edit_chapters(request):
"""Edit chapters"""

39
files/widgets.py Normal file
View File

@@ -0,0 +1,39 @@
import json
from django import forms
from django.utils.safestring import mark_safe
class CategoryModalWidget(forms.SelectMultiple):
"""Two-panel category selector with modal"""
class Media:
css = {'all': ('css/category_modal.css',)}
js = ('js/category_modal.js',)
def render(self, name, value, attrs=None, renderer=None):
# Get all categories as JSON
categories = []
for opt_value, opt_label in self.choices:
if opt_value: # Skip empty choice
categories.append({'id': str(opt_value), 'title': str(opt_label)})
all_categories_json = json.dumps(categories)
selected_ids_json = json.dumps([str(v) for v in (value or [])])
html = f'''<div class="category-widget" data-name="{name}">
<div class="category-content">
<div class="category-panel">
<input type="text" class="category-search" placeholder="Search categories...">
<div class="category-list scrollable" data-panel="left"></div>
</div>
<div class="category-panel">
<h3>Selected Categories</h3>
<div class="category-list scrollable" data-panel="right"></div>
</div>
</div>
<div class="hidden-inputs"></div>
<script type="application/json" class="category-data">{{"all":{all_categories_json},"selected":{selected_ids_json}}}</script>
</div>'''
return mark_safe(html)

View File

@@ -53,9 +53,9 @@ export function PlaylistItem(props) {
<UnderThumbWrapper title={props.title} link={props.link}>
{titleComponent()}
{metaComponents()}
<a href={props.link} title="" className="view-full-playlist">
<span className="view-full-playlist">
VIEW FULL PLAYLIST
</a>
</span>
</UnderThumbWrapper>
</div>
</div>

View File

@@ -108,7 +108,9 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
render() {
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
const mediaState = MediaPageStore.get('media-data').state;
const mediaData = MediaPageStore.get('media-data');
const mediaState = mediaData.state;
const isShared = mediaData.is_shared;
let stateTooltip = '';
@@ -121,6 +123,8 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
break;
}
const sharedTooltip = 'This media is shared with specific users or categories';
return (
<div className="media-title-banner">
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
@@ -129,15 +133,28 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
{'public' !== mediaState ? (
{isShared || 'public' !== mediaState ? (
<div className="media-labels-area">
<div className="media-labels-area-inner">
<span className="media-label-state">
<span>{mediaState}</span>
</span>
<span className="helper-icon" data-tooltip={stateTooltip}>
<i className="material-icons">help_outline</i>
</span>
{isShared ? (
<>
<span className="media-label-state">
<span>shared</span>
</span>
<span className="helper-icon" data-tooltip={sharedTooltip}>
<i className="material-icons">help_outline</i>
</span>
</>
) : 'public' !== mediaState ? (
<>
<span className="media-label-state">
<span>{mediaState}</span>
</span>
<span className="helper-icon" data-tooltip={stateTooltip}>
<i className="material-icons">help_outline</i>
</span>
</>
) : null}
</div>
</div>
) : null}

View File

@@ -10,7 +10,9 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
render() {
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
const mediaState = MediaPageStore.get('media-data').state;
const mediaData = MediaPageStore.get('media-data');
const mediaState = mediaData.state;
const isShared = mediaData.is_shared;
let stateTooltip = '';
@@ -23,6 +25,8 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
break;
}
const sharedTooltip = 'This media is shared with specific users or categories';
return (
<div className="media-title-banner">
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
@@ -31,15 +35,28 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
{'public' !== mediaState ? (
{isShared || 'public' !== mediaState ? (
<div className="media-labels-area">
<div className="media-labels-area-inner">
<span className="media-label-state">
<span>{mediaState}</span>
</span>
<span className="helper-icon" data-tooltip={stateTooltip}>
<i className="material-icons">help_outline</i>
</span>
{isShared ? (
<>
<span className="media-label-state">
<span>shared</span>
</span>
<span className="helper-icon" data-tooltip={sharedTooltip}>
<i className="material-icons">help_outline</i>
</span>
</>
) : 'public' !== mediaState ? (
<>
<span className="media-label-state">
<span>{mediaState}</span>
</span>
<span className="helper-icon" data-tooltip={stateTooltip}>
<i className="material-icons">help_outline</i>
</span>
</>
) : null}
</div>
</div>
) : null}

View File

@@ -32,6 +32,7 @@ const filters = {
{ id: 'private', title: translateString('Private') },
{ id: 'unlisted', title: translateString('Unlisted') },
{ id: 'public', title: translateString('Published') },
{ id: 'shared', title: translateString('Shared') },
],
sort_by: [
{ id: 'date_added_desc', title: translateString('Upload date (newest)') },

View File

@@ -0,0 +1 @@
.category-widget{margin:10px 0}.category-content{display:flex;gap:20px}.category-panel{flex:1;display:flex;flex-direction:column;min-width:0}.category-panel h3{margin:0 0 12px;font-size:16px;font-weight:normal;color:#333}.category-search{width:100%;padding:8px 12px;margin-bottom:12px;border:1px solid #ddd;border-radius:4px;font-size:14px;box-sizing:border-box}.category-list{border:1px solid #ddd;border-radius:4px;padding:8px;background:#f9f9f9;min-height:120px;max-height:200px}.category-list.scrollable{overflow-y:auto}.category-list::-webkit-scrollbar{width:8px}.category-list::-webkit-scrollbar-track{background:#f1f1f1;border-radius:4px}.category-list::-webkit-scrollbar-thumb{background:#ccc;border-radius:4px}.category-list::-webkit-scrollbar-thumb:hover{background:#aaa}.category-item{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;margin-bottom:4px;background:#fff;border:1px solid #e0e0e0;border-radius:4px;font-size:14px;cursor:pointer;transition:all .2s}.category-item:hover{background:#f0f7ff;border-color:#007bff}.category-item span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.category-item button{background:none;border:none;font-size:18px;font-weight:bold;cursor:pointer;padding:0;width:24px;height:24px;display:flex;align-items:center;justify-content:center;border-radius:4px;transition:all .2s}.category-item .add-btn{color:#28a745}.category-item .add-btn:hover{background:rgba(40,167,69,.1)}.category-item .remove-btn{color:#dc3545}.category-item .remove-btn:hover{background:rgba(220,53,69,.1)}.empty-message{padding:30px 15px;text-align:center;color:#999;font-size:13px;font-style:italic}@media (max-width:768px){.category-content{flex-direction:column;gap:16px}.category-list{max-height:150px}}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
document.addEventListener('DOMContentLoaded',()=>{document.querySelectorAll('.category-widget').forEach(w=>{const d=JSON.parse(w.querySelector('.category-data').textContent),all=d.all,sel=new Set(d.selected),name=w.dataset.name,srch=w.querySelector('.category-search'),left=w.querySelector('[data-panel="left"]'),right=w.querySelector('[data-panel="right"]'),inputs=w.querySelector('.hidden-inputs');const upd=()=>{left.innerHTML=all.filter(c=>!sel.has(c.id)&&(!srch.value||c.title.toLowerCase().includes(srch.value.toLowerCase()))).map(c=>`<div class="category-item" data-id="${c.id}"><span>${c.title}</span><button class="add-btn" type="button">+</button></div>`).join('')||'<div class="empty-message">No categories available</div>';right.innerHTML=[...sel].map(id=>all.find(c=>c.id==id)).filter(Boolean).map(c=>`<div class="category-item" data-id="${c.id}"><span>${c.title}</span><button class="remove-btn" type="button">&times;</button></div>`).join('')||'<div class="empty-message">No categories selected</div>';inputs.innerHTML=[...sel].map(id=>`<input type="hidden" name="${name}" value="${id}">`).join('')};srch.oninput=upd;left.onclick=e=>{const item=e.target.closest('.category-item');if(item){sel.add(item.dataset.id);upd()}};right.onclick=e=>{const item=e.target.closest('.category-item');if(item){sel.delete(item.dataset.id);upd()}};upd()})});

File diff suppressed because one or more lines are too long

View File

@@ -4,12 +4,16 @@
<style>
/* Form group styling */
.form-group {
margin-bottom: 1.5rem;
margin-bottom: 1rem;
padding: 1.25rem 1.25rem 0.75rem 1.25rem;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
}
/* Control label container styling */
.control-label-container {
margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
}
/* Label styling */
@@ -56,6 +60,24 @@
border-color: #1e7e34;
}
/* Better input field styling */
.controls input:not([type="checkbox"]):not([type="radio"]),
.controls select,
.controls textarea {
background: white;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 0.5rem 0.75rem;
}
.controls input:not([type="checkbox"]):not([type="radio"]):focus,
.controls select:focus,
.controls textarea:focus {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
</style>
<div class="form-group{% if field.errors %} has-error{% endif %}">

View File

@@ -43,6 +43,14 @@
</li>
{% endcomment %}
{% endif %}
{% if ALLOW_MEDIA_REPLACEMENT %}
<li style="display: inline-block;">
<a href="{% url 'replace_media' %}?m={{media_object.friendly_token}}"
style="text-decoration: none; {% if active_tab == 'replace' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
{{ "Replace" | custom_translate:LANGUAGE_CODE}}
</a>
</li>
{% endif %}
<li style="display: inline-block;">
<a href="{% url 'publish_media' %}?m={{media_object.friendly_token}}"
style="text-decoration: none; {% if active_tab == 'publish' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% load static %}
{% block headtitle %}Replace media - {{PORTAL_NAME}}{% endblock headtitle %}
{% block headermeta %}{% endblock headermeta %}
{% block innercontent %}
<div class="user-action-form-wrap">
{% include "cms/media_nav.html" with active_tab="replace" %}
<div style="max-width: 900px; margin: 0 auto; padding: 20px; border-radius: 8px; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1);">
<div style="margin-bottom: 20px; padding: 15px; background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;">
<h4 style="margin-top: 0; color: #856404;">⚠️ Important Information</h4>
<p style="margin-bottom: 0; color: #856404;">The new file will be processed but all metadata is preserved</p>
</div>
{% csrf_token %}
{% crispy form %}
</div>
</div>
{% endblock innercontent %}