See JSON
diff --git a/cds/modules/deposit/templates/cds_deposit/edit.html b/cds/modules/deposit/templates/cds_deposit/edit.html
index aec1e1b52..5d8d4eaa6 100644
--- a/cds/modules/deposit/templates/cds_deposit/edit.html
+++ b/cds/modules/deposit/templates/cds_deposit/edit.html
@@ -1,6 +1,7 @@
{%- extends "cds_theme/page.html" %}
{%- set title_prefix = record.get('title', {}).get('title', 'Project') -%}
{%- set container_class = '{{container_class}} cds-max-fluid-width' -%}
+{%- set body_css_classes = ['flex-container', 'bootstrap-padding-top', 'cds-deposit-edit-page'] -%}
{%- block css %}
{{ super() }}
diff --git a/cds/modules/flows/tasks.py b/cds/modules/flows/tasks.py
index eaa97952a..833fe4976 100644
--- a/cds/modules/flows/tasks.py
+++ b/cds/modules/flows/tasks.py
@@ -62,7 +62,7 @@
from ..opencast.api import OpenCast
from ..opencast.error import RequestError
from ..opencast.utils import get_qualities
-from ..records.utils import to_string
+from ..records.utils import to_string, parse_video_chapters
from ..xrootd.utils import file_opener_xrootd
from .deposit import index_deposit_project
from .files import dispose_object_version, move_file_into_local
@@ -197,7 +197,9 @@ def _meta_exception_envelope(self, exc):
NOTE: workaround to be able to save the payload in celery in case of
exceptions.
"""
- meta = dict(message=str(exc), payload=self._base_payload)
+ # Safety check in case base payload is not set yet
+ payload = getattr(self, '_base_payload', {})
+ meta = dict(message=str(exc), payload=payload)
return dict(exc_message=meta, exc_type=exc.__class__.__name__)
def on_failure(self, exc, task_id, args, kwargs, einfo):
@@ -223,7 +225,16 @@ def on_success(self, exc, task_id, args, kwargs):
def _reindex_video_project(self):
"""Reindex video and project."""
with celery_app.flask_app.app_context():
- deposit_id = self._base_payload["deposit_id"]
+ # Safety check in case base payload is not set yet
+ if not hasattr(self, '_base_payload') or not self._base_payload or 'deposit_id' not in self._base_payload:
+ if hasattr(self, 'deposit_id') and self.deposit_id:
+ deposit_id = self.deposit_id
+ else:
+ self.log("Cannot reindex: deposit_id not available")
+ return
+ else:
+ deposit_id = self._base_payload["deposit_id"]
+
try:
index_deposit_project(deposit_id)
except PIDDeletedError:
@@ -252,6 +263,7 @@ def set_base_payload(self):
tags=self.object_version.get_tags(),
master_id=self.object_version_id,
)
+ print("********* Base payload set: ", self._base_payload)
def get_full_payload(self, **kwargs):
"""Get full payload merging base payload and kwargs."""
@@ -590,10 +602,10 @@ def progress_updater(current_frame):
object_=self.object_version,
output_dir=output_folder,
progress_updater=progress_updater,
- **options
+ **options,
),
object_=self.object_version,
- **options
+ **options,
)
except Exception:
db.session.rollback()
@@ -601,6 +613,8 @@ def progress_updater(current_frame):
self.clean(version_id=self.object_version_id)
raise
+ total_frames = len(frames)
+
# Generate GIF images
self._create_gif(
bucket=str(self.object_version.bucket.id),
@@ -618,7 +632,7 @@ def progress_updater(current_frame):
db.session.commit()
self.log("Finished task {0}".format(kwargs["task_id"]))
- return "Created {0} frames.".format(len(frames))
+ return "Created {0} frames.".format(total_frames)
@classmethod
def _time_position(cls, duration, frames_start=5, frames_end=95, frames_gap=10):
@@ -648,7 +662,7 @@ def _create_tmp_frames(
duration,
output_dir,
progress_updater=None,
- **kwargs
+ **kwargs,
):
"""Create frames in temporary files."""
# Generate frames
@@ -727,6 +741,268 @@ def _create_object(
[ObjectVersionTag.create(obj, k, to_string(tags[k])) for k in tags]
+class ExtractChapterFramesTask(AVCTask):
+ """Extract chapter frames task - dedicated task for chapter frame extraction only."""
+
+ name = "file_video_extract_chapter_frames"
+
+ @staticmethod
+ def clean(version_id, *args, **kwargs):
+ """Delete generated chapter frame ObjectVersion slaves."""
+ # remove all objects version "slave" with type "frame" that are chapter frames
+ tag_alias_1 = aliased(ObjectVersionTag)
+ tag_alias_2 = aliased(ObjectVersionTag)
+ tag_alias_3 = aliased(ObjectVersionTag)
+
+ slaves = (
+ ObjectVersion.query.join(tag_alias_1, ObjectVersion.tags)
+ .join(tag_alias_2, ObjectVersion.tags)
+ .join(tag_alias_3, ObjectVersion.tags)
+ .filter(tag_alias_1.key == "master", tag_alias_1.value == version_id)
+ .filter(tag_alias_2.key == "context_type", tag_alias_2.value == "frame")
+ .filter(tag_alias_3.key == "is_chapter_frame", tag_alias_3.value == "True")
+ .all()
+ )
+
+ for slave in slaves:
+ dispose_object_version(slave)
+
+ def run(self, *args, **kwargs):
+ """Extract frames only at chapter timestamps from video description.
+
+ This task is specifically designed to extract frames for chapters only,
+ without affecting other frame extraction processes.
+
+ The task receives parameters through the standard AVCTask initialization:
+ - self.deposit_id: The deposit ID containing the video description
+ - self.object_version: The ObjectVersion of the master video file
+ - self.flow_id: The current flow ID for task metadata integration
+ """
+ import tempfile
+ from cds.modules.deposit.api import deposit_video_resolver
+
+ # Initialize task metadata for async execution
+ flow_task_metadata = self.get_or_create_flow_task()
+ kwargs["celery_task_id"] = str(self.request.id)
+ kwargs["task_id"] = str(flow_task_metadata.id)
+ kwargs["flow_id"] = self.flow_id
+ flow_task_metadata.payload = self.get_full_payload(**kwargs)
+ flow_task_metadata.status = FlowTaskStatus.STARTED
+ flow_task_metadata.message = ""
+ db.session.commit()
+
+ self.log("=== Starting ExtractChapterFramesTask (Async) ===")
+ self.log(
+ f"Task parameters: deposit_id={self.deposit_id}, object_version_id={self.object_version_id}, flow_id={self.flow_id}"
+ )
+ self.log(f"Celery task ID: {self.request.id}")
+
+ try:
+ self.log(
+ f"Loaded ObjectVersion: {self.object_version.id} from bucket {self.object_version.bucket_id}"
+ )
+ # Get the deposit to access the description
+ deposit_video = deposit_video_resolver(self.deposit_id)
+ description = deposit_video.get("description", "")
+ self.log(f"Deposit found: {self.deposit_id}")
+ self.log(f"Description length: {len(description)} characters")
+ self.log(f"Description preview: {description[:200]}...")
+
+ # Parse chapters from description
+ chapters = parse_video_chapters(description)
+ self.log(f"Parsed {len(chapters)} chapters from description")
+
+ if not chapters:
+ self.log(
+ "No chapters found in description - task completed with no action"
+ )
+ return {"chapter_frames_extracted": 0, "status": "no_chapters"}
+
+ # Log each chapter for debugging
+ for i, chapter in enumerate(chapters):
+ self.log(
+ f"Chapter {i+1}: {chapter['timestamp']} - {chapter['title'][:50]}..."
+ )
+
+ # Get video duration from metadata
+ duration = float(self._base_payload.get("tags", {}).get("duration", 0))
+ self.log(f"Video duration: {duration} seconds")
+
+ if duration == 0:
+ self.log("ERROR: Video duration is 0 - cannot extract frames")
+ return {
+ "chapter_frames_extracted": 0,
+ "status": "no_duration",
+ "error": "Video duration is 0",
+ }
+
+ # Create temporary directory for frame extraction
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.log(f"Using temporary directory: {temp_dir}")
+
+ chapter_frames = []
+ successful_extractions = 0
+ failed_extractions = 0
+
+ # Get the video file once and reuse it for all chapters
+ with move_file_into_local(
+ self.object_version, delete=False
+ ) as video_url:
+ self.log(f"Video file location: {video_url}")
+
+ for i, chapter in enumerate(chapters):
+ chapter_seconds = chapter["seconds"]
+ chapter_title = chapter["title"]
+ chapter_timestamp = chapter["timestamp"]
+
+ self.log(
+ f"Processing chapter {i+1}/{len(chapters)}: {chapter_timestamp} - {chapter_title}"
+ )
+
+ # Skip chapters that are beyond video duration
+ if chapter_seconds > duration:
+ self.log(
+ f"Skipping chapter at {chapter_seconds}s (beyond duration {duration}s)"
+ )
+ failed_extractions += 1
+ continue
+
+ frame_filename = f"chapter-{chapter_seconds}.jpg"
+ frame_path = os.path.join(temp_dir, frame_filename)
+ self.log(f"Extracting frame to: {frame_path}")
+
+ try:
+ # Use ffmpeg directly to extract a single frame at exact timestamp
+ import subprocess
+
+ cmd = [
+ "ffmpeg",
+ "-y",
+ "-accurate_seek",
+ "-ss",
+ str(chapter_seconds),
+ "-i",
+ video_url,
+ "-vframes",
+ "1",
+ "-qscale:v",
+ "1",
+ frame_path,
+ ]
+ self.log(f"Running ffmpeg command: {' '.join(cmd)}")
+ result = subprocess.run(cmd, capture_output=True, text=True)
+
+ if result.returncode != 0:
+ self.log(f"FFmpeg failed with error: {result.stderr}")
+ failed_extractions += 1
+ continue
+
+ if (
+ os.path.exists(frame_path)
+ and os.path.getsize(frame_path) > 0
+ ):
+ self.log(
+ f"Frame extracted successfully: {frame_path} ({os.path.getsize(frame_path)} bytes)"
+ )
+
+ # Create ObjectVersion for chapter frame
+ with open(frame_path, "rb") as frame_file:
+ obj = ObjectVersion.create(
+ bucket=self.object_version.bucket,
+ key=frame_filename,
+ stream=frame_file,
+ )
+
+ # Tag the frame with metadata
+ ObjectVersionTag.create(
+ obj,
+ "master",
+ str(self.object_version.version_id),
+ )
+ ObjectVersionTag.create(
+ obj, "context_type", "frame"
+ )
+ ObjectVersionTag.create(
+ obj, "is_chapter_frame", "True"
+ )
+ ObjectVersionTag.create(
+ obj, "chapter_timestamp", chapter_timestamp
+ )
+ ObjectVersionTag.create(
+ obj, "chapter_seconds", str(chapter_seconds)
+ )
+ ObjectVersionTag.create(
+ obj, "chapter_title", chapter_title[:200]
+ ) # Limit length
+ ObjectVersionTag.create(
+ obj, "chapter_index", str(i)
+ )
+
+ self.log(
+ f"Created ObjectVersion {obj.version_id} for chapter frame: {frame_filename}"
+ )
+ chapter_frames.append(frame_path)
+ successful_extractions += 1
+
+ else:
+ self.log(
+ f"ERROR: Frame extraction failed - file not created or empty: {frame_path}"
+ )
+ failed_extractions += 1
+
+ except Exception as frame_error:
+ self.log(
+ f"ERROR: Exception extracting frame for chapter {chapter_timestamp}: {frame_error}"
+ )
+ self.log(
+ f"Chapter details: seconds={chapter_seconds}, title={chapter_title}"
+ )
+ import traceback
+
+ self.log(f"Traceback: {traceback.format_exc()}")
+ failed_extractions += 1
+
+ self.log(f"=== Chapter frame extraction completed ===")
+ self.log(f"Total chapters processed: {len(chapters)}")
+ self.log(f"Successful extractions: {successful_extractions}")
+ self.log(f"Failed extractions: {failed_extractions}")
+ self.log(f"Chapter frames created: {len(chapter_frames)}")
+
+ # Update task metadata for successful completion
+ flow_task_metadata.status = FlowTaskStatus.SUCCESS
+ flow_task_metadata.message = (
+ f"Extracted {len(chapter_frames)} chapter frames successfully"
+ )
+ db.session.commit()
+
+ result = {
+ "chapter_frames_extracted": len(chapter_frames),
+ "successful_extractions": successful_extractions,
+ "failed_extractions": failed_extractions,
+ "total_chapters": len(chapters),
+ "status": "completed",
+ }
+
+ self.log(f"Task completed successfully: {result}")
+ return result
+
+ except Exception as e:
+ self.log(f"FATAL ERROR in ExtractChapterFramesTask: {e}")
+ import traceback
+
+ self.log(f"Full traceback: {traceback.format_exc()}")
+
+ # Update task metadata for failure
+ flow_task_metadata.status = FlowTaskStatus.FAILURE
+ flow_task_metadata.message = f"Task failed: {str(e)}"
+ db.session.commit()
+
+ result = {"chapter_frames_extracted": 0, "status": "error", "error": str(e)}
+
+ self.log(f"Task failed: {result}")
+ return result
+
+
class TranscodeVideoTask(AVCTask):
"""Transcode video task.
@@ -793,7 +1069,7 @@ def _update_flow_tasks(self, flow_tasks, status, message, **kwargs):
opencast_publication_tag=current_app.config["CDS_OPENCAST_QUALITIES"][
quality
]["opencast_publication_tag"],
- **kwargs # may contain `opencast_event_id`
+ **kwargs, # may contain `opencast_event_id`
)
# JSONb cols needs to be assigned (not updated) to be persisted
flow_task_metadata.payload = new_payload
@@ -848,7 +1124,7 @@ def _start_transcodable_flow_tasks_or_cancel(self, wanted_qualities=None):
new_payload.update(
task_id=str(t.id),
celery_task_id=str(self.request.id),
- **self._base_payload
+ **self._base_payload,
)
# JSONb cols needs to be assigned (not updated) to be persisted
t.payload = new_payload
diff --git a/cds/modules/previewer/templates/cds_previewer/macros/player.html b/cds/modules/previewer/templates/cds_previewer/macros/player.html
index a7301dd58..cded13ba2 100644
--- a/cds/modules/previewer/templates/cds_previewer/macros/player.html
+++ b/cds/modules/previewer/templates/cds_previewer/macros/player.html
@@ -124,6 +124,74 @@
player.controls = {{ (not embed_config.controlsOff) | default(True) | tojson }};
player.muted = {{ embed_config.muted | default(False) | tojson }};
+ // Add chapters if they exist
+ {% if record and record.get("metadata", {}).get("description") %}
+ function parseChapters(description) {
+ if (!description) return [];
+
+ const pattern = /(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{2})\s*[-\s]*(.+?)(?=\n|$)/gm;
+ const chapters = [];
+ let match;
+
+ while ((match = pattern.exec(description)) !== null) {
+ const [, timestampStr, title] = match;
+
+ const timeParts = timestampStr.split(':');
+ let totalSeconds;
+
+ if (timeParts.length === 2) {
+ const [minutes, seconds] = timeParts.map(Number);
+ totalSeconds = minutes * 60 + seconds;
+ } else if (timeParts.length === 3) {
+ const [hours, minutes, seconds] = timeParts.map(Number);
+ totalSeconds = hours * 3600 + minutes * 60 + seconds;
+ } else {
+ continue;
+ }
+
+ const cleanTitle = title.trim();
+ if (cleanTitle) {
+ chapters.push({
+ startTime: totalSeconds,
+ endTime: totalSeconds + 1,
+ text: cleanTitle
+ });
+ }
+ }
+
+ return chapters.sort((a, b) => a.startTime - b.startTime);
+ }
+
+ player.addEventListener('sourcechange', function() {
+ const description = {{ record.get("metadata", {}).get("description", "") | tojson }};
+ const chapters = parseChapters(description);
+
+ if (chapters.length > 0) {
+ // Create chapter track
+ const chapterTrack = {
+ kind: 'chapters',
+ label: 'Chapters',
+ srclang: 'en',
+ mode: 'hidden',
+ cues: chapters.map(chapter => ({
+ startTime: chapter.startTime,
+ endTime: chapter.endTime,
+ text: chapter.text
+ }))
+ };
+
+ // Add chapter track to player
+ if (player.textTracks) {
+ try {
+ player.textTracks.addTrack(chapterTrack);
+ } catch (e) {
+ console.warn('Could not add chapter track:', e);
+ }
+ }
+ }
+ });
+ {% endif %}
+
{% if theo_config.showTitle and not embed_config.controlsOff %}
// Append the title div
var title = document.createElement('div');
diff --git a/cds/modules/records/serializers/schemas/video.py b/cds/modules/records/serializers/schemas/video.py
index 88fcb3d8c..4d593ff94 100644
--- a/cds/modules/records/serializers/schemas/video.py
+++ b/cds/modules/records/serializers/schemas/video.py
@@ -19,7 +19,7 @@
"""Video JSON schema."""
from invenio_jsonschemas import current_jsonschemas
-from marshmallow import Schema, fields, pre_load, post_load
+from marshmallow import Schema, fields, pre_load, post_load, post_dump
from ....deposit.api import Video
from ..fields.datetime import DateString
@@ -166,6 +166,7 @@ class VideoSchema(StrictKeysSchema):
)
collections = fields.List(fields.Str, many=True)
additional_languages = fields.List(fields.Str, many=True)
+ chapters = fields.List(fields.Dict, dump_only=True)
# Preservation fields
location = fields.Str()
@@ -177,3 +178,16 @@ def post_load(self, data, **kwargs):
"""Post load."""
data["$schema"] = current_jsonschemas.path_to_url(Video._schema)
return data
+
+ @post_dump(pass_many=False)
+ def post_dump(self, data, **kwargs):
+ """Post dump - add parsed chapters."""
+ from cds.modules.records.utils import parse_video_chapters
+
+ description = data.get('description', '')
+ if description:
+ data['chapters'] = parse_video_chapters(description)
+ else:
+ data['chapters'] = []
+
+ return data
diff --git a/cds/modules/records/static/templates/cds_records/video/detail.html b/cds/modules/records/static/templates/cds_records/video/detail.html
index a8444c7e1..20ce6b7ec 100644
--- a/cds/modules/records/static/templates/cds_records/video/detail.html
+++ b/cds/modules/records/static/templates/cds_records/video/detail.html
@@ -4,72 +4,15 @@
-
-
-
-
-
-
-
-
-
-
-
- Transcript Language:
-
-
-
-
-
-
+
+
+
@@ -188,7 +131,7 @@
{{translation.title.title}}
-
+
@@ -258,6 +201,91 @@
{{translation.title.title}}
+
+
+
+
+
+
Chapters
+
+ {{ showAllChapters ? 'Show Less' : 'View All' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ chapter.timestamp }}
+
+
+
+
+
+ {{ cleanHtmlFromTitle(chapter.title) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ chapter.timestamp }}
+
+
+
+
+
+ {{ cleanHtmlFromTitle(chapter.title) }}
+
+
+
+
+
+
+
+
@@ -266,7 +294,7 @@
{{translation.title.title}}
>
-
Transcriptions
+
Transcriptions
Follow along or search within the transcript.
@@ -296,6 +324,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ cleanHtmlFromTitle(chapter.title) }}
+
+
+ {{ chapter.timestamp }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Transcript Language:
+
+
+
+
+
+
diff --git a/cds/modules/records/static/templates/cds_records/video/downloads.html b/cds/modules/records/static/templates/cds_records/video/downloads.html
index aa6e350a8..41953511d 100644
--- a/cds/modules/records/static/templates/cds_records/video/downloads.html
+++ b/cds/modules/records/static/templates/cds_records/video/downloads.html
@@ -86,34 +86,7 @@
-
-
+
diff --git a/cds/modules/records/utils.py b/cds/modules/records/utils.py
index 542e590cb..926baf3df 100644
--- a/cds/modules/records/utils.py
+++ b/cds/modules/records/utils.py
@@ -26,6 +26,8 @@
import json
+import re
+from datetime import timedelta
from html import unescape
from urllib import parse
@@ -482,3 +484,75 @@ def to_string(value):
return value
else:
return json.dumps(value)
+
+
+def parse_video_chapters(description):
+ """Parse YouTube-style chapter timestamps from video description.
+
+ Looks for patterns like:
+ 00:00 Introduction
+ 0:30 Getting Started
+ 1:23:45 Advanced Topics
+
+ Args:
+ description (str): Video description text
+
+ Returns:
+ list: List of chapter dicts with 'timestamp', 'seconds', and 'title' keys
+ """
+ if not description:
+ return []
+
+ # Regex pattern to match timestamp formats:
+ # - 0:00, 00:00, 0:0, 00:0, 0:00:00, 00:00:00, etc.
+ # - Followed by optional space/tab and chapter title
+ pattern = r'(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{1,2})\s*[-\s]*(.+?)(?=\n|$)'
+
+ chapters = []
+ matches = re.findall(pattern, description, re.MULTILINE)
+
+ for timestamp_str, title in matches:
+ # Parse timestamp to seconds
+ time_parts = timestamp_str.split(':')
+ if len(time_parts) == 2: # MM:SS format
+ minutes, seconds = map(int, time_parts)
+ total_seconds = minutes * 60 + seconds
+ elif len(time_parts) == 3: # HH:MM:SS format
+ hours, minutes, seconds = map(int, time_parts)
+ total_seconds = hours * 3600 + minutes * 60 + seconds
+ else:
+ continue
+
+ # Clean up title
+ title = title.strip()
+ if title:
+ chapters.append({
+ 'timestamp': timestamp_str,
+ 'seconds': total_seconds,
+ 'title': title
+ })
+
+ # Sort chapters by timestamp
+ chapters.sort(key=lambda x: x['seconds'])
+
+ return chapters
+
+
+def seconds_to_timestamp(seconds):
+ """Convert seconds to timestamp string (MM:SS or HH:MM:SS).
+
+ Args:
+ seconds (int): Number of seconds
+
+ Returns:
+ str: Formatted timestamp string
+ """
+ td = timedelta(seconds=seconds)
+ hours = td.seconds // 3600
+ minutes = (td.seconds % 3600) // 60
+ secs = td.seconds % 60
+
+ if hours > 0:
+ return f"{hours}:{minutes:02d}:{secs:02d}"
+ else:
+ return f"{minutes}:{secs:02d}"
diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/app.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/app.js
index e77091a46..e1b8c79fc 100644
--- a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/app.js
+++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/app.js
@@ -35,6 +35,7 @@ import "./avc/components/cdsDeposits.js";
import "./avc/components/cdsForm.js";
import "./avc/components/cdsUploader.js";
import "./avc/components/cdsRemoteUploader.js";
+import "./avc/components/cdsVideoList.js";
$(document).ready(function () {
// show warning if IE (not supported for deposit)
diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsActions.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsActions.js
index 504c26514..3567cef9d 100644
--- a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsActions.js
+++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsActions.js
@@ -32,10 +32,47 @@ function cdsActionsCtrl($scope, $q, cdsAPI) {
that.actionHandler("DELETE").then(function () {
var children =
that.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.videos;
- for (var i in children) {
- if (children[i]._deposit.id === that.cdsDepositCtrl.id) {
- children.splice(i, 1);
+ var videoIdToDelete = that.cdsDepositCtrl.id;
+
+ // Find and remove the video from the array
+ var videoIndex = children.findIndex(function(video) {
+ return video._deposit.id === videoIdToDelete;
+ });
+
+ if (videoIndex !== -1) {
+ children.splice(videoIndex, 1);
+
+ // Broadcast deletion event to update other UI components
+ $scope.$broadcast("cds.video.deleted", {
+ videoId: videoIdToDelete,
+ remainingVideos: children.length
+ });
+
+ // Force digest cycle to update UI
+ $scope.$applyAsync();
+ }
+ }).catch(function(error) {
+ // Handle case where video was already deleted
+ if (error.status === 404) {
+ // Video already deleted, just remove from UI
+ var children = that.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.videos;
+ var videoIdToDelete = that.cdsDepositCtrl.id;
+
+ var videoIndex = children.findIndex(function(video) {
+ return video._deposit.id === videoIdToDelete;
+ });
+
+ if (videoIndex !== -1) {
+ children.splice(videoIndex, 1);
+ $scope.$broadcast("cds.video.deleted", {
+ videoId: videoIdToDelete,
+ remainingVideos: children.length
+ });
+ $scope.$applyAsync();
}
+ } else {
+ // Re-throw other errors
+ throw error;
}
});
};
diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsDeposit.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsDeposit.js
index c7de1fa1f..01131a1cd 100644
--- a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsDeposit.js
+++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsDeposit.js
@@ -274,6 +274,42 @@ function cdsDepositCtrl(
}
};
+ this.refreshFormFromBackend = function () {
+ // Fetch latest deposit state from the backend to ensure consistency
+ if (that.links && that.links.self) {
+ cdsAPI.action(that.links.self, "GET", {})
+ .then(function(response) {
+ // Update the record with latest backend state
+ var latestRecord = response.data.metadata;
+
+ // Preserve form changes for specific fields if form is dirty
+ var preserveFields = ['title', 'description', 'contributors'];
+ var updatedRecord = angular.copy(latestRecord);
+
+ if (!that.isPristine()) {
+ preserveFields.forEach(function(field) {
+ if (that.record[field] && !angular.equals(that.record[field], latestRecord[field])) {
+ // Keep the form value if it differs from backend (user might be editing)
+ updatedRecord[field] = that.record[field];
+ }
+ });
+ }
+
+ // Update the record while preserving user changes
+ that.record = _.merge(that.record, updatedRecord);
+
+ // Update deposit status and flows
+ that.updateDeposit(latestRecord);
+ that.fetchFlowTasksStatuses();
+
+ console.log('Form refreshed from backend at', new Date().toISOString());
+ })
+ .catch(function(error) {
+ console.warn('Failed to refresh form from backend:', error);
+ });
+ }
+ };
+
this.refetchRecordOnTaskStatusChanged = function (flowTasksById) {
// init if not set yet
var cachedFlowTasksById = _.isEmpty(that.cachedFlowTasksById)
@@ -281,19 +317,22 @@ function cdsDepositCtrl(
: that.cachedFlowTasksById;
var reFetchRecord = false;
+ var statusHasChanged = false;
for (var taskId in flowTasksById) {
var cachedTask = _.get(cachedFlowTasksById, taskId, null);
if (!cachedTask) {
// something wrong with the cached tasks, maybe a flow restart?
// refetch the record to be sure
reFetchRecord = true;
+ statusHasChanged = true;
break;
}
- var statusHasChanged =
+ var taskStatusChanged =
flowTasksById[taskId]["status"] !== cachedTask["status"];
- if (statusHasChanged) {
+ if (taskStatusChanged) {
reFetchRecord = true;
+ statusHasChanged = true;
break;
}
}
@@ -304,6 +343,15 @@ function cdsDepositCtrl(
if (reFetchRecord) {
that.cdsDepositsCtrl.fetchRecord();
}
+
+ // Broadcast status change to update video list sidebar and other UI components
+ if (statusHasChanged) {
+ $scope.$broadcast("cds.deposit.status.changed", {
+ depositId: that.id,
+ taskStatuses: flowTasksById,
+ currentDepositStatus: that.currentDepositStatus
+ });
+ }
};
this.updateSubformatsTranscodingStatus = function (flowTasks) {
@@ -379,6 +427,20 @@ function cdsDepositCtrl(
if (anyTaskIsRunning) {
that.fetchFlowTasksStatuses();
}
+ // Restart polling with appropriate interval if status changed
+ var currentInterval = getPollingInterval();
+ if (that.fetchStatusInterval.$$intervalId &&
+ (anyTaskIsRunning && currentInterval === 15000) ||
+ (!anyTaskIsRunning && currentInterval === 5000)) {
+ setupSmartPolling();
+ }
+
+ // Ensure UI components stay synchronized during polling
+ $scope.$broadcast("cds.deposit.polling.update", {
+ depositId: that.id,
+ anyTaskIsRunning: anyTaskIsRunning,
+ currentDepositStatus: that.currentDepositStatus
+ });
};
// Update deposit based on extracted metadata from task
@@ -471,6 +533,9 @@ function cdsDepositCtrl(
}
that.currentStartedTaskName = currentStartedTaskName;
+ // Store previous status to detect changes
+ var previousDepositStatus = that.currentDepositStatus;
+
// Change the Deposit Status
var values = _.values(that.record._cds.state);
if (!values.length) {
@@ -484,6 +549,17 @@ function cdsDepositCtrl(
} else {
that.currentDepositStatus = depositStatuses.SUCCESS;
}
+
+ // Broadcast status change to update video list sidebar and other UI components
+ if (previousDepositStatus !== that.currentDepositStatus) {
+ $scope.$broadcast("cds.deposit.status.changed", {
+ depositId: that.id,
+ previousStatus: previousDepositStatus,
+ currentStatus: that.currentDepositStatus,
+ currentStartedTaskName: that.currentStartedTaskName,
+ taskStates: that.record._cds.state
+ });
+ }
};
// Check if extracted metadata is available for automatic form fill
@@ -552,16 +628,51 @@ function cdsDepositCtrl(
that.videoPreviewer();
// Update subformat statuses
that.fetchFlowTasksStatuses();
- that.fetchStatusInterval = $interval(
- that.recurrentFetchFlowTasksStatuses,
- 5000
- );
- // What the order of contributors and check make it dirty, throttle the
- // function for 1sec
+ // Smart polling: use different intervals based on task activity
+ var getPollingInterval = function() {
+ var anyTaskIsRunning = that.anyTaskIsRunning();
+ return anyTaskIsRunning ? 5000 : 15000; // 5s when active, 15s when idle
+ };
+
+ var setupSmartPolling = function() {
+ if (that.fetchStatusInterval) {
+ $interval.cancel(that.fetchStatusInterval);
+ }
+ that.fetchStatusInterval = $interval(
+ that.recurrentFetchFlowTasksStatuses,
+ getPollingInterval()
+ );
+ };
+
+ setupSmartPolling();
+
+ // Setup 5-minute interval for form re-initialization
+ var formRefreshInterval = $interval(function() {
+ that.refreshFormFromBackend();
+ }, 300000); // 5 minutes = 300000ms
+
+ // Clean up form refresh interval on component destroy
+ var originalDestroy = that.$onDestroy;
+ that.$onDestroy = function() {
+ try {
+ $interval.cancel(formRefreshInterval);
+ } catch (error) {}
+ if (originalDestroy) {
+ originalDestroy.call(that);
+ }
+ };
+ // Watch contributors with optimized shallow watching and increased debounce
+ // Use shallow watching and manual comparison for better performance
+ var lastContributorsState = null;
$scope.$watch(
"$ctrl.record.contributors",
- _.throttle(that.setDirty, 1000),
- true
+ _.debounce(function(newVal, oldVal) {
+ if (newVal && (!lastContributorsState || !angular.equals(newVal, lastContributorsState))) {
+ lastContributorsState = angular.copy(newVal);
+ that.setDirty();
+ }
+ }, 2000),
+ false // Changed from deep watching (true) to shallow watching (false)
);
$scope.$watch("$ctrl.record._deposit.status", function () {
$scope.$applyAsync(function () { // Manually trigger UI updates
@@ -576,8 +687,8 @@ function cdsDepositCtrl(
});
});
- // Listen for task status changes
- $scope.$on("cds.deposit.task", function (evt, type, status, data) {
+ // Listen for task status changes - use throttling to prevent excessive updates
+ var taskStatusHandler = _.throttle(function (evt, type, status, data) {
if (type == "file_video_metadata_extraction" && status == "SUCCESS") {
var allMetadata = data.payload.extracted_metadata;
that.setOnLocalStorage("metadata", allMetadata);
@@ -585,26 +696,55 @@ function cdsDepositCtrl(
} else if (type == "file_video_extract_frames" && status == "SUCCESS") {
that.framesReady = true;
}
- });
+ }, 500);
+ $scope.$on("cds.deposit.task", taskStatusHandler);
+
+ // Cache status check results to avoid frequent recalculation
+ var _lastDepositStatus = null;
+ var _cachedStatusChecks = {};
+
+ function _getCachedStatusCheck(statusType) {
+ if (that.currentDepositStatus !== _lastDepositStatus) {
+ _cachedStatusChecks = {};
+ _lastDepositStatus = that.currentDepositStatus;
+ }
+ return _cachedStatusChecks[statusType];
+ }
this.displayFailure = function () {
- return that.currentDepositStatus === that.depositStatuses.FAILURE;
+ var cached = _getCachedStatusCheck('failure');
+ if (cached === undefined) {
+ cached = _cachedStatusChecks['failure'] = that.currentDepositStatus === that.depositStatuses.FAILURE;
+ }
+ return cached;
};
this.displayPending = function () {
- return that.currentDepositStatus === that.depositStatuses.PENDING;
+ var cached = _getCachedStatusCheck('pending');
+ if (cached === undefined) {
+ cached = _cachedStatusChecks['pending'] = that.currentDepositStatus === that.depositStatuses.PENDING;
+ }
+ return cached;
};
this.displayStarted = function () {
- return that.currentDepositStatus === that.depositStatuses.STARTED;
+ var cached = _getCachedStatusCheck('started');
+ if (cached === undefined) {
+ cached = _cachedStatusChecks['started'] = that.currentDepositStatus === that.depositStatuses.STARTED;
+ }
+ return cached;
};
this.displaySuccess = function () {
- return (
- that.currentDepositStatus === that.depositStatuses.SUCCESS &&
- !that.isPublished() &&
- !that.record.recid
- );
+ var cached = _getCachedStatusCheck('success');
+ if (cached === undefined) {
+ cached = _cachedStatusChecks['success'] = (
+ that.currentDepositStatus === that.depositStatuses.SUCCESS &&
+ !that.isPublished() &&
+ !that.record.recid
+ );
+ }
+ return cached;
};
this.postSuccessProcess = function (responses) {
diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsUploader.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsUploader.js
index ce70be33d..2dc64d6e1 100644
--- a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsUploader.js
+++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsUploader.js
@@ -276,61 +276,66 @@ function cdsUploaderCtrl(
this.addFiles = function (_files, invalidFiles, extraHeaders) {
// Do nothing if files array is empty
- if (!_files) {
+ if (!_files || _files.length === 0) {
return;
}
// Remove any invalid files
_files = _.difference(_files, invalidFiles || []);
- // Filter out files without a valid MIME type or with zero size
- _files = _files.filter((file) => {
+ // Filter out files without a valid MIME type or with zero size - optimized with early returns
+ var validFiles = [];
+ var invalidMessages = [];
+
+ for (var i = 0; i < _files.length; i++) {
+ var file = _files[i];
if (!file.type || file.type.trim() === "") {
- toaster.pop(
- "warning",
- "Invalid File Type",
- `The file ${file.name} has no valid type.`
- );
- return false; // Exclude invalid files
+ invalidMessages.push(`The file ${file.name} has no valid type.`);
+ continue;
}
-
if (!file.size || file.size === 0) {
- toaster.pop(
- "warning",
- "Empty File",
- `The file ${file.name} is empty and cannot be uploaded.`
- );
- return false; // Exclude zero-size files
+ invalidMessages.push(`The file ${file.name} is empty and cannot be uploaded.`);
+ continue;
}
-
- return true;
- });
-
- // Make sure they have proper metadata
- angular.forEach(_files, function (file) {
+ validFiles.push(file);
+ }
+
+ // Show all invalid file messages at once
+ if (invalidMessages.length > 0) {
+ toaster.pop(
+ "warning",
+ "Invalid Files",
+ invalidMessages.join(" "),
+ { bodyOutputType: "trustedHtml" }
+ );
+ }
+
+ _files = validFiles;
+
+ // Optimize file processing - batch operations and reduce iterations
+ var keysToRemove = [];
+ var defaultHeaders = { "X-Invenio-File-Tags": "context_type=additional_file" };
+
+ for (var i = 0; i < _files.length; i++) {
+ var file = _files[i];
file.key = file.name;
file.local = !file.receiver;
file.isAdditional = true;
- // Add any extra paramemters to the files
- if (extraHeaders) {
- file.headers = extraHeaders;
- }
-
- if (!extraHeaders || !("X-Invenio-File-Tags" in extraHeaders)) {
- file.headers = {
- "X-Invenio-File-Tags": "context_type=additional_file",
- };
- }
- });
+
+ // Set headers efficiently
+ file.headers = extraHeaders && ("X-Invenio-File-Tags" in extraHeaders)
+ ? extraHeaders
+ : (extraHeaders ? angular.merge({}, extraHeaders, defaultHeaders) : defaultHeaders);
+
+ // Collect keys to remove in one pass
+ keysToRemove.push(file.key);
+ }
- // Find if any of the existing files has been replaced
- // (file with same filename), and if yes remove it from the existing
- // file list (aka from the interface).
- _files = _.each(_files, function (file) {
- // Remove the existing file from the list
- _.remove(that.files, function (_f) {
- return _f.key === file.key;
+ // Remove existing files with same keys in one efficient operation
+ if (keysToRemove.length > 0) {
+ that.files = that.files.filter(function(f) {
+ return keysToRemove.indexOf(f.key) === -1;
});
- });
+ }
if ((invalidFiles || []).length > 0) {
// Push a notification
@@ -537,8 +542,32 @@ function cdsUploaderCtrl(
}
};
+ // Cache file indices for better performance in frequent lookups
+ var _fileIndexCache = {};
+ var _lastFilesLength = 0;
+
this.findFileIndex = function (files, key) {
- return _.indexOf(files, _.find(that.files, { key: key }));
+ // Invalidate cache if files array changed
+ if (that.files.length !== _lastFilesLength) {
+ _fileIndexCache = {};
+ _lastFilesLength = that.files.length;
+ }
+
+ // Check cache first
+ if (_fileIndexCache[key] !== undefined) {
+ return _fileIndexCache[key];
+ }
+
+ // Find and cache the index
+ for (var i = 0; i < that.files.length; i++) {
+ if (that.files[i].key === key) {
+ _fileIndexCache[key] = i;
+ return i;
+ }
+ }
+
+ _fileIndexCache[key] = -1;
+ return -1;
};
this.validateSubtitles = function (_file) {
diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsVideoList.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsVideoList.js
new file mode 100644
index 000000000..5aff55787
--- /dev/null
+++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsVideoList.js
@@ -0,0 +1,649 @@
+import angular from "angular";
+import _ from "lodash";
+
+function cdsVideoListCtrl($scope, $timeout) {
+ var that = this;
+
+ // State management for video list
+ this.selectedVideoId = null;
+ this.expandedVideoId = null;
+ this.loadingVideo = null;
+
+ // Filter state
+ this.statusFilter = 'all'; // 'all', 'published', 'draft'
+
+ // Performance optimization: cache computed values
+ this._statusCache = new Map();
+ this._titleCache = new Map();
+ this._thumbnailCache = new Map();
+
+ // Clear cache when videos change
+ var clearCache = function() {
+ that._statusCache.clear();
+ that._titleCache.clear();
+ that._thumbnailCache.clear();
+ };
+
+ // Initialize component
+ this.$onInit = function() {
+ // Auto-select and expand first video if available
+ if (that.videos && that.videos.length > 0) {
+ that.selectedVideoId = that.videos[0]._deposit.id;
+ // Auto-expand first video after a short delay
+ $timeout(function() {
+ that.expandedVideoId = that.videos[0]._deposit.id;
+ }, 100);
+ }
+
+ // Listen for deposit status changes to clear cache and update UI
+ $scope.$on("cds.deposit.status.changed", function(event, statusData) {
+ if (statusData && statusData.depositId) {
+ // Clear cache for the specific video that had status changes
+ that._statusCache.delete(statusData.depositId);
+ that._titleCache.delete(statusData.depositId);
+ that._thumbnailCache.delete(statusData.depositId);
+
+ // Force UI update by triggering digest cycle
+ $scope.$applyAsync();
+ }
+ });
+
+ // Listen for metadata updates which can also affect status display
+ $scope.$on("cds.deposit.metadata.update", function(event, data) {
+ // Clear all cache when metadata updates occur
+ clearCache();
+ $scope.$applyAsync();
+ });
+
+ // Listen for polling updates to ensure UI synchronization
+ $scope.$on("cds.deposit.polling.update", function(event, pollingData) {
+ if (pollingData && pollingData.depositId) {
+ // Clear cache for the specific video during polling to ensure fresh status
+ that._statusCache.delete(pollingData.depositId);
+ }
+ });
+
+ // Listen for video deletion events to update UI
+ $scope.$on("cds.video.deleted", function(event, deletionData) {
+ if (deletionData && deletionData.videoId) {
+ // Clear cache for the deleted video
+ that._statusCache.delete(deletionData.videoId);
+ that._titleCache.delete(deletionData.videoId);
+ that._thumbnailCache.delete(deletionData.videoId);
+
+ // If the deleted video was selected, clear selection
+ if (that.selectedVideoId === deletionData.videoId) {
+ that.selectedVideoId = null;
+ that.expandedVideoId = null;
+
+ // Auto-select first remaining video if any
+ if (that.videos && that.videos.length > 0) {
+ that.selectedVideoId = that.videos[0]._deposit.id;
+ $timeout(function() {
+ that.expandedVideoId = that.videos[0]._deposit.id;
+ }, 100);
+ }
+ }
+
+ // Remove from expanded descriptions set
+ that.expandedDescriptions.delete(deletionData.videoId);
+
+ // Force UI update
+ $scope.$applyAsync();
+ }
+ });
+
+ // Watch for video changes and clear cache
+ $scope.$watch('$ctrl.videos', function(newVideos, oldVideos) {
+ if (newVideos !== oldVideos) {
+ clearCache();
+
+ // Handle video addition/removal
+ if (newVideos && oldVideos) {
+ // Check if new videos were added
+ if (newVideos.length > oldVideos.length) {
+ // Find the newly added video(s)
+ var newVideoIds = newVideos.map(v => v._deposit.id);
+ var oldVideoIds = oldVideos.map(v => v._deposit.id);
+ var addedVideoIds = newVideoIds.filter(id => !oldVideoIds.includes(id));
+
+ // Auto-select the newest added video
+ if (addedVideoIds.length > 0) {
+ var newestVideoId = addedVideoIds[addedVideoIds.length - 1];
+ that.selectedVideoId = newestVideoId;
+ $timeout(function() {
+ that.expandedVideoId = newestVideoId;
+ }, 300);
+ }
+ }
+ }
+
+ // Auto-select first video when videos are loaded initially
+ if (newVideos && newVideos.length > 0 && !that.selectedVideoId) {
+ that.selectedVideoId = newVideos[0]._deposit.id;
+ $timeout(function() {
+ that.expandedVideoId = newVideos[0]._deposit.id;
+ }, 100);
+ }
+ }
+ }, true); // Deep watch to detect array changes
+ };
+
+ // Select a video from the list
+ this.selectVideo = function(videoId) {
+ // If clicking on the same video that's already selected and expanded, do nothing
+ if (that.selectedVideoId === videoId && that.expandedVideoId === videoId) {
+ return;
+ }
+
+ // If switching to a different video, trigger autosave for the currently expanded video
+ if (that.expandedVideoId && that.expandedVideoId !== videoId) {
+ $scope.$broadcast("cds.deposit.project.saveAll");
+ }
+
+ that.selectedVideoId = videoId;
+ that.loadingVideo = videoId;
+
+ // Small delay to show loading state
+ $timeout(function() {
+ that.expandedVideoId = videoId;
+ that.loadingVideo = null;
+
+ // Emit event to notify parent components
+ $scope.$emit('cds.video.selected', videoId);
+ }, 300);
+ };
+
+ // Get video status for display (cached)
+ this.getVideoStatus = function(video) {
+ var videoId = video._deposit.id;
+ var cached = that._statusCache.get(videoId);
+ if (cached) return cached;
+
+ var result;
+ if (!video._cds || !video._cds.state) {
+ result = { status: 'draft', label: 'Draft', class: 'label-default' };
+ } else {
+ var state = video._cds.state;
+ var anyFailed = Object.values(state).some(s => s === 'FAILURE');
+ var anyRunning = Object.values(state).some(s => s === 'STARTED' || s === 'PENDING');
+ var allSuccess = Object.values(state).every(s => s === 'SUCCESS');
+
+ if (anyFailed) {
+ result = { status: 'failed', label: 'Processing Failed', class: 'label-danger' };
+ } else if (anyRunning) {
+ result = { status: 'processing', label: 'Processing', class: 'label-warning' };
+ } else if (allSuccess) {
+ result = { status: 'ready', label: 'Ready to Publish', class: 'label-success' };
+ } else {
+ result = { status: 'draft', label: 'Draft', class: 'label-default' };
+ }
+ }
+
+ that._statusCache.set(videoId, result);
+ return result;
+ };
+
+ // Check if video is published
+ this.isVideoPublished = function(video) {
+ return video._deposit.status === 'published';
+ };
+
+ // Get video item CSS classes
+ this.getVideoItemClasses = function(video) {
+ var classes = [];
+
+ if (that.isSelected(video._deposit.id)) {
+ classes.push('active');
+ }
+
+ if (that.isLoading(video._deposit.id)) {
+ classes.push('loading');
+ }
+
+ if (that.isVideoPublished(video)) {
+ classes.push('video-published');
+ } else {
+ classes.push('video-editing');
+ }
+
+ return classes.join(' ');
+ };
+
+ // Get video title for display (cached)
+ this.getVideoTitle = function(video) {
+ var videoId = video._deposit.id;
+ var cached = that._titleCache.get(videoId);
+ if (cached) return cached;
+
+ var result;
+ if (video.title && video.title.title) {
+ result = video.title.title;
+ } else if (video._files && video._files.length > 0) {
+ // Try to get title from the main video file
+ var mainFile = video._files.find(f => f.context_type === 'master');
+ if (mainFile) {
+ result = mainFile.key || 'Untitled Video';
+ } else {
+ result = video._files[0].key || 'Untitled Video';
+ }
+ } else {
+ result = 'Untitled Video';
+ }
+
+ that._titleCache.set(videoId, result);
+ return result;
+ };
+
+ // Get video thumbnail or placeholder
+ this.getVideoThumbnail = function(video) {
+ // Try to find frame from files
+ if (video._files) {
+ var frameFile = video._files.find(f => f.key && f.key.includes('frame-'));
+ if (frameFile && frameFile.links && frameFile.links.self) {
+ return frameFile.links.self + '?width=120&height=68';
+ }
+ }
+
+ // Return placeholder
+ return '/static/img/video-placeholder.svg';
+ };
+
+
+ // Check if video is selected
+ this.isSelected = function(videoId) {
+ return that.selectedVideoId === videoId;
+ };
+
+ // Check if video is expanded
+ this.isExpanded = function(videoId) {
+ return that.expandedVideoId === videoId;
+ };
+
+ // Check if video is loading
+ this.isLoading = function(videoId) {
+ return that.loadingVideo === videoId;
+ };
+
+ // Get progress for processing videos
+ this.getProcessingProgress = function(video) {
+ if (!video._cds || !video._cds.state) return 0;
+
+ var tasks = Object.keys(video._cds.state);
+ var completedTasks = tasks.filter(task =>
+ video._cds.state[task] === 'SUCCESS' || video._cds.state[task] === 'FAILURE'
+ ).length;
+
+ return Math.round((completedTasks / tasks.length) * 100);
+ };
+
+ // Get video description for display
+ this.getVideoDescription = function(video) {
+ // Try different possible paths for description
+ if (video.description) {
+ if (typeof video.description === 'string') {
+ return video.description;
+ } else if (video.description.description) {
+ return video.description.description;
+ }
+ }
+
+ // Try abstract field as fallback
+ if (video.abstract) {
+ if (typeof video.abstract === 'string') {
+ return video.abstract;
+ } else if (video.abstract.summary) {
+ return video.abstract.summary;
+ }
+ }
+
+ // Debug: log the video object structure (can be removed later)
+ console.log('Video object for description debugging:', video);
+
+ // Fallback message
+ return 'No description available';
+ };
+
+ // Track expanded descriptions
+ this.expandedDescriptions = new Set();
+
+ // Toggle description expansion
+ this.toggleDescription = function(videoId, event) {
+ // Stop event from bubbling to prevent video selection
+ if (event) {
+ event.stopPropagation();
+ }
+
+ if (that.expandedDescriptions.has(videoId)) {
+ that.expandedDescriptions.delete(videoId);
+ } else {
+ that.expandedDescriptions.add(videoId);
+ }
+ };
+
+ // Check if description is expanded
+ this.isDescriptionExpanded = function(videoId) {
+ return that.expandedDescriptions.has(videoId);
+ };
+
+ // Filter videos based on status
+ this.getFilteredVideos = function() {
+ if (!that.videos) return [];
+
+ switch (that.statusFilter) {
+ case 'published':
+ return that.videos.filter(function(video) {
+ return that.isVideoPublished(video);
+ });
+ case 'draft':
+ return that.videos.filter(function(video) {
+ return !that.isVideoPublished(video);
+ });
+ default:
+ return that.videos;
+ }
+ };
+
+ // Set status filter
+ this.setStatusFilter = function(filter) {
+ that.statusFilter = filter;
+
+ // If current selected video is not in filtered results, clear selection
+ var filteredVideos = that.getFilteredVideos();
+ var selectedVideoInFilter = filteredVideos.find(function(video) {
+ return video._deposit.id === that.selectedVideoId;
+ });
+
+ if (!selectedVideoInFilter && filteredVideos.length > 0) {
+ // Auto-select first video in filtered results
+ that.selectedVideoId = filteredVideos[0]._deposit.id;
+ $timeout(function() {
+ that.expandedVideoId = filteredVideos[0]._deposit.id;
+ }, 100);
+ } else if (filteredVideos.length === 0) {
+ // Clear selection if no videos match filter
+ that.selectedVideoId = null;
+ that.expandedVideoId = null;
+ }
+ };
+
+ // Get filter counts
+ this.getFilterCounts = function() {
+ if (!that.videos) return { all: 0, published: 0, draft: 0 };
+
+ var published = that.videos.filter(function(video) {
+ return that.isVideoPublished(video);
+ }).length;
+
+ var draft = that.videos.length - published;
+
+ return {
+ all: that.videos.length,
+ published: published,
+ draft: draft
+ };
+ };
+}
+
+cdsVideoListCtrl.$inject = ['$scope', '$timeout'];
+
+function cdsVideoList() {
+ return {
+ transclude: true,
+ bindings: {
+ videos: '=',
+ maxNumberOfVideos: '<',
+ isPublished: '&'
+ },
+ require: {
+ cdsDepositsCtrl: '^cdsDeposits'
+ },
+ controller: cdsVideoListCtrl,
+ template: `
+
+ `
+ };
+}
+
+angular.module('cdsDeposit.components').component('cdsVideoList', cdsVideoList());
\ No newline at end of file
diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js b/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js
index 8ec43af26..dc7e72852 100644
--- a/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js
+++ b/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js
@@ -39,7 +39,7 @@ import { getCookie } from "../getCookie";
* @description
* CDS record controller.
*/
-function cdsRecordController($scope, $sce, $http, $timeout) {
+function cdsRecordController($scope, $sce, $http, $timeout, $filter) {
// Parameters
// Assign the controller to `vm`
@@ -59,6 +59,10 @@ function cdsRecordController($scope, $sce, $http, $timeout) {
$scope.filteredTranscript = [];
$scope.selectedTranscriptLanguage = null;
$scope.transcriptSearch = "";
+ $scope.chapters = [];
+ $scope.showChapters = true;
+ $scope.showAllChapters = false;
+ $scope.activeTab = 'chapters'; // Default to chapters tab
const REQUEST_HEADERS = {
"Content-Type": "application/json",
@@ -178,6 +182,24 @@ function cdsRecordController($scope, $sce, $http, $timeout) {
console.warn("No subtitle file found.");
}
});
+
+ // Use chapters from API or parse from description as fallback
+ if (record.metadata.chapters && record.metadata.chapters.length > 0) {
+ $scope.chapters = record.metadata.chapters;
+ } else if (record.metadata.description) {
+ $scope.chapters = $scope.parseChapters(record.metadata.description);
+ }
+
+ // Set default active tab based on what's available (prioritize chapters)
+ const hasTranscripts = (record.metadata._files || []).some(f =>
+ f.context_type === 'subtitle' && f.content_type === 'vtt'
+ );
+
+ if ($scope.chapters.length > 0) {
+ $scope.activeTab = 'chapters';
+ } else if (hasTranscripts) {
+ $scope.activeTab = 'transcript';
+ }
};
$scope.setTranscriptLanguage = function (lang) {
@@ -261,6 +283,125 @@ function cdsRecordController($scope, $sce, $http, $timeout) {
return `${minutes}:${paddedSecs}`;
};
+ $scope.parseChapters = function (description) {
+ if (!description) return [];
+
+ // Regex pattern to match timestamp formats: 0:00, 00:00, 0:00:00, 00:00:00
+ const pattern = /(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{2})\s*[-\s]*(.+?)(?=\n|$)/gm;
+ const chapters = [];
+ let match;
+
+ while ((match = pattern.exec(description)) !== null) {
+ const [, timestampStr, title] = match;
+
+ // Parse timestamp to seconds
+ const timeParts = timestampStr.split(':');
+ let totalSeconds;
+
+ if (timeParts.length === 2) { // MM:SS format
+ const [minutes, seconds] = timeParts.map(Number);
+ totalSeconds = minutes * 60 + seconds;
+ } else if (timeParts.length === 3) { // HH:MM:SS format
+ const [hours, minutes, seconds] = timeParts.map(Number);
+ totalSeconds = hours * 3600 + minutes * 60 + seconds;
+ } else {
+ continue;
+ }
+
+ // Clean up title
+ const cleanTitle = title.trim();
+ if (cleanTitle) {
+ chapters.push({
+ timestamp: timestampStr,
+ seconds: totalSeconds,
+ title: cleanTitle
+ });
+ }
+ }
+
+ // Sort chapters by timestamp
+ chapters.sort((a, b) => a.seconds - b.seconds);
+ return chapters;
+ };
+
+ $scope.toggleChapters = function () {
+ $scope.showChapters = !$scope.showChapters;
+ };
+
+ $scope.setActiveTab = function (tab) {
+ $scope.activeTab = tab;
+ };
+
+ $scope.toggleInThisVideoFromMain = function () {
+ $scope.showTranscript = !$scope.showTranscript;
+
+ // If opening, scroll to the "In this video" section and set chapters tab
+ if ($scope.showTranscript && $scope.chapters.length > 0) {
+ $scope.activeTab = 'chapters';
+ setTimeout(function () {
+ const el = document.getElementById('transcriptionsSection');
+ if (el) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }, 100);
+ }
+ };
+
+ $scope.processDescriptionWithClickableTimestamps = function (description) {
+ if (!description) return description;
+
+ // Regex pattern to match timestamp formats: 0:00, 00:00, 0:00:00, 00:00:00
+ const pattern = /(\d{1,2}:(?:\d{1,2}:)?\d{2})/g;
+
+ return description.replace(pattern, function(match) {
+ // Parse timestamp to seconds for the seek function
+ const timeParts = match.split(':');
+ let totalSeconds;
+
+ if (timeParts.length === 2) {
+ const [minutes, seconds] = timeParts.map(Number);
+ totalSeconds = minutes * 60 + seconds;
+ } else if (timeParts.length === 3) {
+ const [hours, minutes, seconds] = timeParts.map(Number);
+ totalSeconds = hours * 3600 + minutes * 60 + seconds;
+ } else {
+ return match; // Return unchanged if invalid format
+ }
+
+ // Return clickable timestamp using onclick for ng-bind-html compatibility
+ return `${match} `;
+ });
+ };
+
+ $scope.getChapterFrame = function (chapter) {
+ if (!$scope.record || !chapter) return null;
+
+ // Use the findMaster filter to get the master file (this filter is defined in cds/module.js)
+ const master = $filter('findMaster')($scope.record);
+
+ if (!master || !master.frame) return null;
+
+ // Look for a frame with filename that matches chapter timestamp
+ // Chapter frames are named like "chapter-{seconds}.jpg"
+ const expectedFrameName = `chapter-${chapter.seconds}.jpg`;
+
+ const chapterFrame = master.frame.find(frame =>
+ frame.key === expectedFrameName
+ );
+
+ return chapterFrame || null;
+ };
+
+ $scope.cleanHtmlFromTitle = function (title) {
+ if (!title) return title;
+
+ // Remove HTML tags and clean up whitespace for display purposes only
+ let cleanTitle = title.replace(/<[^>]+>/g, ' ');
+ cleanTitle = cleanTitle.replace(/\s+/g, ' ').trim();
+
+ return cleanTitle;
+ };
+
/**
* Trust iframe url
* @memberof cdsRecordController
@@ -383,7 +524,7 @@ function cdsRecordController($scope, $sce, $http, $timeout) {
$scope.$on("cds.record.loading.stop", cdsRecordLoadingStop);
}
-cdsRecordController.$inject = ["$scope", "$sce", "$http", "$timeout"];
+cdsRecordController.$inject = ["$scope", "$sce", "$http", "$timeout", "$filter"];
////////////
diff --git a/cds/modules/theme/assets/bootstrap3/scss/cds/cds.scss b/cds/modules/theme/assets/bootstrap3/scss/cds/cds.scss
index c923bc8f4..9ffdf55c9 100644
--- a/cds/modules/theme/assets/bootstrap3/scss/cds/cds.scss
+++ b/cds/modules/theme/assets/bootstrap3/scss/cds/cds.scss
@@ -62,9 +62,9 @@ html, body {
}
.cds-deposit-panel {
- max-width: 1000px;
- margin-left: auto;
- margin-right: auto;
+ // max-width: 1000px;
+ // margin-left: auto;
+ // margin-right: auto;
.is-sticky {
// the height of the navbar
margin-top: 65px !important;
@@ -438,6 +438,1109 @@ a.cds-anchor:hover{
background-color: #fff;
}
+// Expand container width for deposit pages
+.cds-max-fluid-width {
+ max-width: 95% !important;
+ margin: 0 auto;
+}
+
+// Override container constraints for deposit edit pages
+body.cds-deposit-edit-page {
+ .container-fluid.cds-max-fluid-width {
+ max-width: 98% !important;
+ width: 98% !important;
+ margin: 0 auto;
+ padding: 0 15px;
+
+ // For larger desktop monitors (1440px+), use contained layout
+ @media (min-width: 1440px) {
+ max-width: 1350px !important;
+ width: 1350px !important;
+ padding: 0 30px;
+ }
+
+ // For very large screens (1920px+), more contained
+ @media (min-width: 1920px) {
+ max-width: 1600px !important;
+ width: 1600px !important;
+ padding: 0 40px;
+ }
+ }
+}
+
+// Wide container for video deposit interface
+.cds-video-deposit-container {
+ // Full width up to larger desktop screens (MacBook Pro 13" and similar)
+ max-width: 98%;
+ margin: 0 auto;
+ padding: 0 15px 50px 15px; // Added bottom padding to prevent footer overlap
+
+ // For larger desktop monitors (1440px+), use contained layout
+ @media (min-width: 1440px) {
+ max-width: 1350px;
+ margin: 0 auto;
+ padding: 0 30px 50px 30px;
+ }
+
+ // For very large screens (1920px+), more contained
+ @media (min-width: 1920px) {
+ max-width: 1600px;
+ margin: 0 auto;
+ padding: 0 40px 50px 40px;
+ }
+
+ // Ensure project section uses full width efficiently
+ .cds-deposit-panel-project {
+ .panel-body {
+ padding: 20px 30px;
+
+ // Better spacing for project form layout
+ .row {
+ margin-left: -20px;
+ margin-right: -20px;
+
+ .col-sm-6 {
+ padding-left: 20px;
+ padding-right: 20px;
+ }
+
+ .col-sm-12 {
+ padding-left: 20px;
+ padding-right: 20px;
+ }
+ }
+
+ // Improve form field spacing in project
+ .form-group {
+ margin-bottom: 20px;
+
+ label {
+ font-weight: 500;
+ margin-bottom: 8px;
+ }
+
+ .form-control {
+ padding: 8px 12px;
+ font-size: 14px;
+ }
+ }
+
+ // Better button spacing
+ .btn {
+ margin: 5px;
+ border-radius: 6px;
+ font-weight: 500;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12);
+ transition: all 0.2s ease;
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 6px rgba(0,0,0,0.15);
+ }
+
+ &.btn-success {
+ background: #5cb85c;
+ border-color: #4cae4c;
+ color: #fff;
+ }
+
+ &.btn-primary {
+ background: #337ab7;
+ border-color: #2e6da4;
+ color: #fff;
+ }
+
+ &.btn-warning {
+ background: #f0ad4e;
+ border-color: #eea236;
+ color: #fff;
+ }
+
+ &.btn-sm {
+ padding: 6px 12px;
+ }
+ }
+
+ // Improve help text spacing
+ .text-muted {
+ margin-top: 15px;
+ line-height: 1.5;
+ }
+ }
+
+ // Project header styling to match video list aesthetic
+ .panel-heading {
+ background: #f8f9fa;
+ border-bottom: 1px solid #e9ecef;
+ padding: 15px 30px;
+
+ .text-muted {
+ font-size: 16px;
+ font-weight: 500;
+ color: #495057;
+
+ i {
+ margin-right: 8px;
+ }
+ }
+
+ .pull-right {
+ .btn {
+ margin-left: 8px;
+ border-radius: 6px;
+ font-weight: 500;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12);
+ transition: all 0.2s ease;
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 6px rgba(0,0,0,0.15);
+ }
+
+ &.btn-success {
+ background: #5cb85c;
+ border-color: #4cae4c;
+ color: #fff;
+ }
+
+ &.btn-primary {
+ background: #337ab7;
+ border-color: #2e6da4;
+ color: #fff;
+ }
+
+ &.btn-warning {
+ background: #f0ad4e;
+ border-color: #eea236;
+ color: #fff;
+ }
+ }
+ }
+ }
+ }
+
+ // Improve uploader section styling within project
+ .cds-deposit-box {
+ margin: 20px 0;
+ border: 2px dashed #dee2e6;
+ border-radius: 8px;
+ background: #f8f9fa;
+ transition: all 0.3s ease;
+
+ &:hover {
+ border-color: #adb5bd;
+ background: #f1f3f4;
+ }
+
+ .cds-deposit-box-upload-wrapper {
+ padding: 40px 20px;
+
+ .cds-deposit-box-upload-icon {
+ color: #6c757d;
+ }
+
+ .cds-deposit-box-upload-title h3 {
+ color: #495057;
+ font-weight: 500;
+ margin-bottom: 10px;
+ }
+
+ .cds-deposit-box-upload-description {
+ color: #6c757d;
+ font-size: 14px;
+ }
+ }
+ }
+}
+
+// Video List Sidebar Component Styles
+.cds-video-list-sidebar {
+ // Make sure we use full width
+ width: 100%;
+
+ .video-list-header {
+ border-bottom: 1px solid #e9ecef;
+ padding-bottom: 15px;
+ margin-bottom: 20px;
+
+ @media (max-width: 768px) {
+ .col-md-8, .col-md-4 {
+ width: 100%;
+ text-align: left !important;
+ margin-bottom: 10px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+
+ .video-sidebar-layout {
+ min-height: calc(100vh - 300px);
+ margin-bottom: 50px;
+
+ // Adjust proportions to prevent tab wrapping
+ .col-lg-4, .col-md-5 {
+ // Reduce sidebar width more to give tabs space
+ @media (min-width: 992px) {
+ width: 28% !important; // Further reduced from 30%
+ padding-right: 5px; // Reduce right padding
+ }
+ @media (min-width: 768px) and (max-width: 991px) {
+ width: 32% !important; // Further reduced from 35%
+ padding-right: 5px;
+ }
+ }
+
+ .col-lg-8, .col-md-7 {
+ // Increase main content width more
+ @media (min-width: 992px) {
+ width: 72% !important; // Increased from 70%
+ padding-left: 5px; // Reduce left padding
+ }
+ @media (min-width: 768px) and (max-width: 991px) {
+ width: 68% !important; // Increased from 65%
+ padding-left: 5px;
+ }
+ }
+
+ // Responsive adjustments for mobile
+ @media (max-width: 767px) {
+ .col-lg-4, .col-md-5 {
+ width: 100%;
+ margin-bottom: 20px;
+ }
+ .col-lg-8, .col-md-7 {
+ width: 100%;
+ }
+ }
+
+ // Left sidebar
+ .video-sidebar {
+ background: #f8f9fa;
+ border-right: 1px solid #e9ecef;
+ height: auto;
+ min-height: 650px; // Header + 5 videos + upload area
+ display: flex;
+ flex-direction: column;
+
+ // Status Filter Section
+ .video-sidebar-filter {
+ padding: 15px;
+ border-bottom: 1px solid #e9ecef;
+ background: #fff;
+ flex-shrink: 0;
+
+ .filter-header {
+ margin-bottom: 10px;
+
+ small {
+ font-weight: 500;
+ color: #495057;
+
+ i {
+ margin-right: 5px;
+ color: #6c757d;
+ }
+ }
+ }
+
+ .filter-buttons {
+ .btn-group {
+ width: 100%;
+ display: flex;
+
+ .filter-btn {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ text-align: center;
+ padding: 8px 4px;
+ font-size: 11px;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ overflow: hidden;
+
+ i {
+ margin-bottom: 2px;
+ font-size: 12px;
+ }
+
+ .badge {
+ background-color: #6c757d;
+ font-size: 9px;
+ padding: 1px 4px;
+ border-radius: 8px;
+ margin-top: 2px;
+ min-width: 16px;
+ text-align: center;
+ }
+
+ &.btn-primary .badge {
+ background-color: #fff;
+ color: #007bff;
+ }
+
+ &.btn-success .badge {
+ background-color: #fff;
+ color: #28a745;
+ }
+
+ &.btn-warning .badge {
+ background-color: #fff;
+ color: #ffc107;
+ }
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+
+ // Responsive text adjustments
+ @media (max-width: 1200px) {
+ font-size: 10px;
+ padding: 6px 2px;
+
+ i {
+ font-size: 11px;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .video-sidebar-header {
+ padding: 15px;
+ border-bottom: 1px solid #e9ecef;
+ background: #fff;
+ flex-shrink: 0;
+ }
+
+ .video-sidebar-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px 0;
+ max-height: 550px; // Height for approximately 5 videos (110px per video)
+ min-height: 550px;
+
+ .video-list-item {
+ cursor: pointer;
+ padding: 12px 15px;
+ border-bottom: 1px solid #e9ecef;
+ transition: all 0.2s ease;
+ border-left: 3px solid transparent;
+ min-height: 120px; // Increased height to accommodate expanded descriptions
+ display: flex;
+ align-items: flex-start; // Changed from center to flex-start
+
+ &:hover {
+ background-color: #fff;
+ border-left-color: #ddd;
+ }
+
+ &.active {
+ background-color: #e7f3ff;
+ border-left-color: #007bff;
+
+ .video-title {
+ color: #007bff;
+ font-weight: 600;
+ }
+ }
+
+ &.loading {
+ opacity: 0.7;
+ }
+
+ // Published video styling
+ &.video-published {
+ background-color: #e8f5e9;
+ border-left-color: #28a745;
+
+ .video-title {
+ color: #155724;
+ }
+
+ &:hover {
+ background-color: #d4edda;
+ border-left-color: #28a745;
+ }
+
+ &.active {
+ background-color: #c3e6cb;
+ border-left-color: #28a745;
+
+ .video-title {
+ color: #155724;
+ font-weight: 600;
+ }
+ }
+ }
+
+ // Editing (draft) video styling
+ &.video-editing {
+ background-color: #fff3cd;
+ border-left-color: #fd7e14;
+
+ &:hover {
+ background-color: #ffeaa7;
+ border-left-color: #fd7e14;
+ }
+
+ &.active {
+ background-color: #ffe082;
+ border-left-color: #fd7e14;
+
+ .video-title {
+ color: #8a4800;
+ font-weight: 600;
+ }
+ }
+ }
+
+ .video-item-content {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ }
+
+ .video-thumbnail-sidebar {
+ position: relative;
+ flex-shrink: 0;
+ align-self: flex-start; // Keep thumbnail at top
+ margin-top: 8px; // Add some top margin for alignment
+
+ img {
+ width: 80px;
+ height: 45px;
+ object-fit: cover;
+ border-radius: 4px;
+ border: 1px solid #e9ecef;
+ }
+
+ .loading-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ color: white;
+ }
+ }
+
+ .video-item-details {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ .video-header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+
+ .video-title-section {
+ flex: 1;
+ min-width: 0;
+
+ .video-title {
+ margin: 0 0 4px 0;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-height: 2.6em;
+ }
+
+ .video-meta {
+ .video-number {
+ font-size: 10px;
+ color: #6c757d;
+ font-weight: 500;
+ }
+ }
+ }
+
+ .video-actions {
+ flex-shrink: 0;
+ display: flex;
+ align-items: flex-end;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 80px;
+ text-align: right;
+
+ .video-status-published {
+ display: flex;
+ align-items: flex-end;
+ flex-direction: column;
+ gap: 4px;
+ width: 100%;
+
+ .label {
+ font-size: 9px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-weight: 500;
+ align-self: flex-end;
+ }
+
+ .visit-video-btn {
+ font-size: 9px;
+ padding: 3px 8px;
+ border-radius: 3px;
+ text-decoration: none;
+ background: #007bff;
+ border-color: #007bff;
+ color: #fff;
+ transition: all 0.2s ease;
+ align-self: flex-end;
+
+ &:hover {
+ background: #0056b3;
+ border-color: #0056b3;
+ color: #fff;
+ text-decoration: none;
+ transform: translateY(-1px);
+ box-shadow: 0 1px 3px rgba(0,0,0,0.15);
+ }
+
+ i {
+ margin-right: 2px;
+ font-size: 8px;
+ }
+ }
+ }
+
+ .video-status-editing {
+ width: 100%;
+ text-align: right;
+
+ .label {
+ font-size: 9px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-weight: 500;
+ }
+ }
+ }
+ }
+
+ .video-progress-section {
+ .progress-xs {
+ height: 3px;
+ margin: 0;
+ background-color: #e9ecef;
+ border-radius: 2px;
+
+ .progress-bar {
+ line-height: 3px;
+ }
+ }
+ }
+
+ .video-description-section {
+ .video-description {
+ position: relative;
+
+ .description-text {
+ margin: 0;
+ font-size: 11px;
+ line-height: 1.4;
+ color: #6c757d;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-height: 2.8em;
+ transition: max-height 0.3s ease;
+
+ &.no-description {
+ color: #adb5bd;
+ font-style: italic;
+ font-size: 10px;
+ }
+ }
+
+ &.expanded .description-text {
+ display: block;
+ -webkit-line-clamp: unset;
+ max-height: none;
+ overflow: visible;
+ }
+
+ .description-toggle-text {
+ margin-top: 4px;
+ cursor: pointer;
+
+ .show-more,
+ .show-less {
+ font-size: 10px;
+ color: #007bff;
+ font-weight: 500;
+ text-decoration: underline;
+ transition: color 0.2s ease;
+
+ &:hover {
+ color: #0056b3;
+ text-decoration: none;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Upload area in sidebar (using original style)
+ .video-sidebar-upload {
+ margin-top: 10px;
+
+ .upload-divider {
+ height: 1px;
+ background: #e9ecef;
+ margin: 15px 0;
+ }
+
+ .sidebar-upload-box {
+ margin: 0 15px 15px 15px;
+
+ .cds-deposit-box-upload-wrapper {
+ padding: 25px 15px;
+
+ .cds-deposit-box-upload-icon {
+ font-size: 20px;
+ margin-bottom: 10px;
+
+ i {
+ color: $btn-info-bg;
+ }
+ }
+
+ .cds-deposit-box-upload-title h5 {
+ color: $gray-dark;
+ margin-bottom: 8px;
+ font-size: 16px;
+ }
+
+ .cds-deposit-box-upload-description {
+ color: $gray;
+ font-size: 13px;
+ margin: 0;
+ }
+
+ &:hover {
+ .cds-deposit-box-upload-icon i {
+ color: $primary-color;
+ }
+ }
+ }
+ }
+ }
+
+ // Max videos message in sidebar
+ .video-sidebar-max-message {
+ margin-top: 10px;
+
+ .upload-divider {
+ height: 1px;
+ background: #e9ecef;
+ margin: 15px 0;
+ }
+
+ .max-videos-message {
+ padding: 12px 15px;
+ margin: 0 15px;
+ background: #fff3cd;
+ border: 1px solid #ffeaa7;
+ border-radius: 6px;
+ font-size: 12px;
+ text-align: center;
+ color: #856404;
+
+ i {
+ margin-right: 6px;
+ color: #f39c12;
+ }
+ }
+ }
+ }
+
+ // Right editor area
+ .video-editor-area {
+ padding-left: 10px; // Reduced from 20px to 10px
+ height: calc(100vh - 300px);
+ min-height: 500px;
+ margin-bottom: 30px;
+
+ // Make forms use full height without scrollbar
+ .video-editor-content {
+ height: 100%;
+
+ .video-details-container {
+ height: 100%;
+
+ // Video form container
+ .panel.panel-default {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+
+ .panel-body {
+ flex: 1;
+ overflow: visible;
+ padding: 20px;
+
+ // Tab content should fill available space
+ .tab-content {
+ height: calc(100% - 60px); // Account for tab nav height
+
+ .tab-pane {
+ height: 100%;
+ overflow-y: auto;
+ padding: 10px 0;
+
+ // Form sections
+ .row {
+ margin-bottom: 15px;
+
+ .col-sm-6, .col-sm-12 {
+ .form-group {
+ margin-bottom: 15px;
+
+ .form-control {
+ border-radius: 4px;
+ }
+
+ // Textarea adjustments
+ textarea.form-control {
+ min-height: 80px;
+ resize: vertical;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Tab navigation
+ .nav-tabs {
+ border-bottom: 1px solid #ddd;
+ margin-bottom: 10px;
+ flex-shrink: 0;
+
+ // Ensure tabs don't wrap and have enough space
+ display: flex;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ white-space: nowrap;
+
+ li {
+ flex-shrink: 0;
+
+ a {
+ white-space: nowrap;
+ padding: 8px 12px;
+ font-size: 13px;
+
+ // Ensure Admin tab and others fit properly
+ @media (min-width: 768px) {
+ padding: 10px 16px;
+ font-size: 14px;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @media (max-width: 768px) {
+ padding-left: 0;
+ margin-top: 20px;
+ }
+
+ .video-editor-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ background: #f8f9fa;
+ border: 2px dashed #dee2e6;
+ border-radius: 8px;
+ transition: all 0.3s ease;
+
+ &:hover {
+ border-color: #adb5bd;
+ }
+ }
+
+ .video-editor-header {
+ background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+ border: 1px solid #dee2e6;
+ border-bottom: 2px solid #007bff;
+ padding: 18px 25px;
+ border-radius: 6px 6px 0 0;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ margin-bottom: 20px;
+
+
+ .video-header-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ h4 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ flex: 1;
+ color: #495057;
+
+ i.fa-video-camera {
+ color: #007bff;
+ margin-right: 8px;
+ }
+
+ small {
+ font-weight: 500;
+ color: #6c757d;
+ background: #e9ecef;
+ padding: 2px 8px;
+ border-radius: 12px;
+ margin-left: 10px;
+ font-size: 12px;
+ }
+ }
+
+ .video-header-actions {
+ flex-shrink: 0;
+ margin-left: 15px;
+
+ // Style action buttons for better integration
+ .btn {
+ margin-left: 8px;
+ border-radius: 6px;
+ font-weight: 500;
+ padding: 8px 16px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12);
+ transition: all 0.2s ease;
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 6px rgba(0,0,0,0.15);
+ }
+
+ &.btn-success {
+ background: #5cb85c;
+ border-color: #4cae4c;
+ color: #fff;
+ }
+
+ &.btn-primary {
+ background: #337ab7;
+ border-color: #2e6da4;
+ color: #fff;
+ }
+
+ &.btn-warning {
+ background: #f0ad4e;
+ border-color: #eea236;
+ color: #fff;
+ }
+
+ &.btn-sm {
+ padding: 6px 12px;
+ font-size: 13px;
+ }
+ }
+ }
+ }
+ }
+
+ .video-editor-content {
+ border: 1px solid #e9ecef;
+ border-radius: 0 0 4px 4px;
+ background: #fff;
+
+ .video-details-container {
+ padding: 20px;
+
+ // Reset panel styles to integrate better - but preserve form functionality
+ > .cds-deposit-panel {
+ margin-bottom: 0;
+ border: none;
+ box-shadow: none;
+
+ // Keep the panel heading but style it for better integration
+ > .panel-heading[hl-sticky] {
+ background: #f8f9fa;
+ border: none;
+ border-bottom: 1px solid #e9ecef;
+ padding: 10px 15px;
+ margin: -20px -20px 20px -20px;
+
+ // Hide the redundant video title and info, but keep actions
+ .text-muted {
+ display: none;
+ }
+
+ // Keep actions visible and properly positioned
+ .pull-right {
+ display: block;
+ float: right;
+
+ // Style action buttons for better integration
+ .btn {
+ margin-left: 5px;
+ border-radius: 4px;
+
+ &.btn-success, &.btn-primary {
+ font-weight: 500;
+ }
+
+ &.btn-sm {
+ padding: 5px 10px;
+ font-size: 12px;
+ }
+ }
+ }
+ }
+ }
+
+ // Keep form panels intact for proper field rendering
+ .panel.panel-default {
+ border: 1px solid #ddd;
+
+ .panel-heading {
+ display: block !important;
+ background-color: #f5f5f5;
+ padding: 10px 15px;
+ }
+
+ .panel-body {
+ padding: 15px;
+ }
+ }
+
+ // Ensure form controls work properly
+ .form-control {
+ display: block;
+ width: 100%;
+ padding: 6px 12px;
+ font-size: 14px;
+ line-height: 1.42857143;
+ color: #555;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ }
+
+ // Fix for CKEditor and textarea in the editor area
+ textarea.form-control {
+ height: auto;
+ min-height: 80px;
+ resize: vertical;
+ width: 100%;
+ padding: 6px 12px;
+ font-size: 14px;
+ line-height: 1.42857143;
+ color: #555;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ }
+
+ // Ensure CKEditor instances render properly
+ .cke {
+ border: 1px solid #ccc;
+ }
+ }
+ }
+ }
+ }
+
+ // Legacy compact view styles (for backward compatibility)
+ .video-list-compact {
+ .video-list-item {
+ cursor: pointer;
+ transition: all 0.2s ease;
+ border-left: 3px solid transparent;
+
+ &:hover {
+ background-color: #f8f9fa;
+ border-left-color: #ddd;
+ }
+
+ &.active {
+ background-color: #e7f3ff;
+ border-left-color: #007bff;
+
+ .list-group-item-heading {
+ color: #007bff;
+ font-weight: 600;
+ }
+ }
+
+ &.loading {
+ opacity: 0.7;
+ }
+ }
+
+ .video-thumbnail {
+ position: relative;
+
+ .loading-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ color: white;
+ }
+ }
+
+ .progress-xs {
+ height: 4px;
+ margin-bottom: 0;
+
+ .progress-bar {
+ line-height: 4px;
+ }
+ }
+ }
+
+ // Empty state styling
+ .py-40 {
+ padding: 40px 0;
+ }
+}
+
.cds-pills-center {
display: flex;
justify-content: center;
@@ -1122,6 +2225,79 @@ div[cds-search-results] {
background-color: $gray-lighter;
}
+// Chapter styling
+.chapter-item {
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: #e8f4fd;
+ }
+
+ &:last-child {
+ border-bottom: none !important;
+ }
+}
+
+.chapter-timestamp {
+ color: #2196F3;
+ font-weight: 600;
+}
+
+// Main chapters section styling
+.cds-detail-chapters {
+ .chapter-item-main {
+ &:hover {
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+ transform: translateY(-1px);
+ }
+
+ .chapter-timestamp-main {
+ .badge {
+ font-family: monospace;
+ }
+ }
+
+ .chapter-title-main {
+ font-size: 14px;
+ line-height: 1.4;
+ }
+ }
+
+ // Responsive adjustments
+ @media (max-width: 768px) {
+ .chapter-item-main {
+ margin-bottom: 10px;
+
+ div[style*="display: flex"] {
+ flex-direction: column !important;
+ align-items: flex-start !important;
+
+ .chapter-thumbnail {
+ margin-bottom: 8px;
+ margin-right: 0 !important;
+ }
+
+ .chapter-info {
+ .chapter-timestamp-main {
+ margin-bottom: 5px;
+ }
+ }
+ }
+ }
+ }
+
+ // Chapter thumbnail styling
+ .chapter-thumbnail {
+ img {
+ transition: transform 0.2s ease;
+ }
+
+ &:hover img {
+ transform: scale(1.05);
+ }
+ }
+}
+
.transcription-button {
border-radius: 9px !important;
border: 1px solid $cds-primary-color !important;
@@ -1135,4 +2311,426 @@ div[cds-search-results] {
.transcription-close:hover {
cursor: pointer;
+}
+
+// Transcription section alignment with video player
+.cds-detail-video {
+ // When transcription is active, ensure proper alignment
+ .container-fluid .row {
+ margin-left: 0;
+ margin-right: 0;
+ align-items: flex-start;
+ // Add top margin to account for fixed header
+ margin-top: 20px;
+
+ // Video column when transcription is active
+ .col-md-8 {
+ padding-left: 0;
+ padding-right: 15px;
+
+ .cds-video-iframe {
+ // Reset all margins to ensure perfect alignment
+ margin: 0;
+ padding: 0;
+
+ // Use more stable sizing that works better with zoom
+ width: 100%;
+
+ // Fallback for browsers without aspect-ratio support
+ height: 56.25vw; // 16:9 aspect ratio fallback
+
+ // Modern browsers with aspect-ratio support
+ aspect-ratio: 16/9;
+ height: auto;
+
+ max-width: 100%;
+ max-height: 80vh;
+
+ // Responsive adjustments for smaller screens
+ @media (max-width: 991px) {
+ height: 56.25vw; // Fallback
+ aspect-ratio: 16/9;
+ height: auto; // Override for modern browsers
+ max-height: 60vh;
+ }
+
+ iframe {
+ width: 100% !important;
+ height: 100% !important;
+ max-width: 100%;
+ max-height: 100%;
+ margin: 0;
+ padding: 0;
+ vertical-align: top;
+ }
+ }
+ }
+
+ // Transcription column
+ .col-md-4 {
+ padding-left: 15px;
+ padding-right: 0;
+
+ // Ensure transcription section aligns with video top
+ #transcriptionsSection {
+ margin: 0 !important;
+ padding: 0;
+ height: auto;
+ min-height: 400px;
+ overflow-y: auto;
+ border-top: none;
+ position: relative;
+ z-index: 10;
+ background: #fff;
+
+ // Override ALL inherited spacing classes
+ &.mt-20, &.mb-30, &.pt-10, &.pb-10 {
+ margin: 0 !important;
+ padding-top: 0 !important;
+ padding-bottom: 0 !important;
+ }
+
+ // Responsive height adjustment
+ @media (max-width: 991px) {
+ height: auto;
+ max-height: 400px;
+ margin-top: 20px;
+ }
+
+ // Transcription content styling
+ .panel {
+ max-height: calc(100% - 200px);
+
+ .panel-body {
+ max-height: inherit;
+
+ .list-unstyled {
+ .transcript-line {
+ padding: 8px 12px;
+ margin-bottom: 0;
+ border-bottom: 1px solid #f0f0f0;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: #f8f9fa;
+ }
+
+ &.active {
+ background-color: #e3f2fd;
+ border-left: 3px solid #2196f3;
+ }
+
+ strong {
+ color: #666;
+ font-size: 11px;
+ }
+
+ .cds-deposit-frame-thumbnail-label {
+ margin-top: 4px;
+ font-size: 13px;
+ line-height: 1.4;
+ color: #333;
+ }
+ }
+ }
+ }
+ }
+
+ // Search input styling
+ .form-control {
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ font-size: 14px;
+
+ &:focus {
+ border-color: #2196f3;
+ box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
+ }
+ }
+
+ // Language select styling
+ #languageSelect {
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ font-size: 14px;
+ }
+
+ // Header styling
+ h3 {
+ color: #333;
+ font-size: 18px;
+ margin: 0;
+ padding: 0 0 10px 0;
+ background: #fff;
+ position: relative;
+ z-index: 15;
+ border-bottom: 1px solid #eee;
+
+ .fa-close {
+ opacity: 0.7;
+ transition: opacity 0.2s ease;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+
+ // Remove ALL spacing from transcription container and children
+ .pb-20, .pt-10, .mt-20, .mb-30, .px-20 {
+ margin: 0 !important;
+ padding: 10px !important;
+ }
+
+ // Ensure the first child element (header) has no extra margin
+ > div:first-child {
+ margin: 0 !important;
+ padding: 10px !important;
+ }
+
+ // Force container to start at top
+ > * {
+ &:first-child {
+ margin-top: 0 !important;
+ padding-top: 10px !important;
+ }
+ }
+ }
+ }
+ }
+}
+
+// Video Keywords styling
+.video-keywords-row {
+ margin-top: 5px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 3px;
+
+ .keyword-pill {
+ font-size: 10px;
+ padding: 2px 6px;
+ margin: 0;
+ border-radius: 10px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 80px;
+ }
+
+ .keyword-pill-more {
+ font-size: 10px;
+ padding: 2px 6px;
+ margin: 0;
+ border-radius: 10px;
+ background-color: #5bc0de;
+ border-color: #46b8da;
+ }
+}
+
+// Video detail chapters and transcript styles
+.chapters-main-horizontal,
+.chapters-main-horizontal-additional {
+ &::-webkit-scrollbar {
+ height: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: #f1f1f1;
+ border-radius: 3px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #c1c1c1;
+ border-radius: 3px;
+
+ &:hover {
+ background: #a8a8a8;
+ }
+ }
+}
+
+// Transcript line styling
+.transcript-line {
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: #f8f9fa !important;
+ border-left: 3px solid #e3f2fd;
+ padding-left: 11px !important;
+ }
+
+ &.active {
+ background-color: #e3f2fd !important;
+ border-left: 4px solid #2196F3;
+ padding-left: 10px !important;
+ box-shadow: 0 2px 4px rgba(33, 150, 243, 0.1);
+
+ .transcript-timestamp {
+ color: #1976D2 !important;
+ font-weight: 700 !important;
+ }
+
+ .transcript-text {
+ color: #333 !important;
+ font-weight: 500 !important;
+ }
+ }
+}
+
+// In this video panel styles
+.in-this-video-panel {
+ .tabs-container {
+ margin-bottom: 15px;
+ margin-top: 10px;
+
+ .nav-tabs {
+ border-bottom: 1px solid #ddd;
+
+ a {
+ padding: 10px 14px;
+ font-size: 14px;
+ }
+ }
+ }
+
+ .search-container {
+ margin-bottom: 15px;
+
+ .form-control {
+ font-size: 14px;
+ }
+ }
+
+ .content-container {
+ max-height: 300px;
+ overflow-y: auto;
+ margin-bottom: 15px;
+ }
+
+ .language-selector {
+ border-top: 1px solid #eee;
+ padding-top: 15px;
+ margin-bottom: 0;
+
+ label {
+ font-size: 13px;
+ color: #666;
+ margin-bottom: 6px;
+ }
+
+ .form-control {
+ font-size: 14px;
+ }
+ }
+}
+
+// Chapter item styles
+.chapter-item {
+ cursor: pointer;
+ padding: 12px;
+ border-bottom: 1px solid #f0f0f0;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: #f8f9fa;
+ }
+
+ .chapter-content {
+ display: flex;
+ align-items: center;
+
+ .chapter-thumbnail {
+ min-width: 80px;
+ margin-right: 12px;
+
+ .thumbnail-container {
+ width: 80px;
+ height: 45px;
+ border-radius: 4px;
+ overflow: hidden;
+ background-color: #f5f5f5;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .thumbnail-placeholder {
+ width: 100%;
+ height: 100%;
+ background-color: #e9ecef;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .fa {
+ color: #6c757d;
+ font-size: 16px;
+ }
+ }
+ }
+ }
+
+ .chapter-info {
+ flex: 1;
+
+ .chapter-title {
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 1.3;
+ margin-bottom: 4px;
+ }
+
+ .chapter-timestamp {
+ font-size: 14px;
+ color: #2196F3;
+ font-weight: 600;
+ }
+ }
+ }
+}
+
+// Transcript item styles
+.transcript-item {
+ cursor: pointer;
+ padding: 8px 10px;
+ border-radius: 3px;
+ margin-bottom: 6px;
+
+ .transcript-timestamp {
+ font-size: 14px;
+ color: #2196F3;
+ font-weight: 600;
+ margin-bottom: 4px;
+ }
+
+ .transcript-text {
+ font-size: 15px;
+ line-height: 1.4;
+ }
+}
+
+// Responsive styles for chapters
+@media (max-width: 768px) {
+ .chapter-item-horizontal-main {
+ width: 150px !important;
+ }
+
+ .chapter-thumbnail-main-horizontal {
+ width: 126px !important;
+ height: 71px !important;
+ }
+}
+
+@media (max-width: 480px) {
+ .chapter-item-horizontal-main {
+ width: 130px !important;
+ }
+
+ .chapter-thumbnail-main-horizontal {
+ width: 106px !important;
+ height: 60px !important;
+ }
}
\ No newline at end of file
diff --git a/cds/modules/theme/assets/bootstrap3/scss/cds_previewer/video.scss b/cds/modules/theme/assets/bootstrap3/scss/cds_previewer/video.scss
index 896bee114..3f0071a30 100644
--- a/cds/modules/theme/assets/bootstrap3/scss/cds_previewer/video.scss
+++ b/cds/modules/theme/assets/bootstrap3/scss/cds_previewer/video.scss
@@ -92,4 +92,5 @@ video {
.theoplayer-skin.light:hover .vjs-big-play-button:after,
.theoplayer-skin.light .vjs-big-play-button:focus:after {
opacity: 0;
-}
\ No newline at end of file
+}
+
diff --git a/cds/modules/theme/static/img/video-placeholder.svg b/cds/modules/theme/static/img/video-placeholder.svg
new file mode 100644
index 000000000..04f08a244
--- /dev/null
+++ b/cds/modules/theme/static/img/video-placeholder.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/recent-changes.md b/recent-changes.md
new file mode 100644
index 000000000..1c9408e3d
--- /dev/null
+++ b/recent-changes.md
@@ -0,0 +1,77 @@
+# Recent Changes Summary
+
+## Last 15 Commits (Latest to Oldest)
+
+### 799ae1f7 - claude: add chapters via deposit description
+- Added chapter functionality through deposit description
+
+### 635b1fd1 - claude: fix IOS fullscreen issue
+- Fixed fullscreen video playback issues on iOS devices
+
+### e5b856f6 - claude: remove toggleable layout and keep new filtered list
+- Removed toggleable layout option
+- Maintained new filtered list functionality
+
+### 2aeedeab - claude: add video list filters and related_identifiers field
+- Added filtering capabilities to video lists
+- Introduced related_identifiers field
+
+### e6399b2f - claude: fix delete video timing issue
+- Resolved timing problems when deleting videos
+
+### 33455871 - claude: improve polling mechanism for status updates
+- Enhanced polling system for better status update handling
+
+### 95e53e4f - remove CLAUDE.md
+- Removed CLAUDE.md file from repository
+
+### 355f3120 - gitignore: CLAUDE.md
+- Added CLAUDE.md to gitignore
+
+### 16e5158d - claude: refine deposit videos list and transcription css
+- Improved styling for deposit videos list
+- Enhanced transcription CSS
+
+### 124c1370 - claude: fix toggling on side panel and display keywords
+- Fixed side panel toggle functionality
+- Improved keyword display
+
+### f44eb4cf - claude: add list views for video editing
+- Added list view functionality for video editing interface
+
+### 6432e385 - claude: deposit form performance optimizations
+- Optimized deposit form performance
+
+### 262c8c9b - detail: fix toggle transcript css
+- Fixed CSS issues with transcript toggle
+
+### 1adb60c2 - detail: fix transcript filtering
+- Resolved transcript filtering issues
+
+### 3241f6ea - templates: rename related_indico_section to related_event_section and remove Indico from section label
+- Renamed related_indico_section to related_event_section
+- Removed Indico branding from section labels
+
+## Summary Categories
+
+### UI/UX Improvements
+- iOS fullscreen fixes
+- List view enhancements
+- Side panel and keyword display improvements
+- CSS refinements for transcription and deposit lists
+
+### Feature Additions
+- Chapter functionality via deposit descriptions
+- Video list filtering capabilities
+- Related identifiers field
+- List views for video editing
+
+### Performance & Technical
+- Deposit form performance optimizations
+- Improved polling mechanism for status updates
+- Video deletion timing fixes
+- Transcript filtering improvements
+
+### Configuration & Maintenance
+- CLAUDE.md file management
+- Template naming updates (Indico → Event)
\ No newline at end of file