mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-12-10 05:52:31 -05:00
Compare commits
8 Commits
c035bcddf5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeef8284bf | ||
|
|
a90fcbf8dd | ||
|
|
1b3cdfd302 | ||
|
|
cd7dd4f72c | ||
|
|
9b3d9fe1e7 | ||
|
|
ea340b6a2e | ||
|
|
ba2c31b1e6 | ||
|
|
5eb6fafb8c |
@@ -69,7 +69,7 @@ Copyright Markos Gogoulos.
|
|||||||
|
|
||||||
## Support and paid services
|
## Support and paid services
|
||||||
|
|
||||||
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Contact us at info@mediacms.io for more information.
|
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Checkout our [services page](https://mediacms.io/#services/) for more information.
|
||||||
|
|
||||||
### Commercial Hostings
|
### Commercial Hostings
|
||||||
**Elestio**
|
**Elestio**
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VERSION = "7.2.1"
|
VERSION = "7.2.2"
|
||||||
|
|||||||
@@ -910,7 +910,9 @@ def trim_video_method(media_file_path, timestamps_list):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
||||||
output_file = os.path.join(temp_dir, "output.mp4")
|
# Detect input file extension to preserve original format
|
||||||
|
_, input_ext = os.path.splitext(media_file_path)
|
||||||
|
output_file = os.path.join(temp_dir, f"output{input_ext}")
|
||||||
|
|
||||||
segment_files = []
|
segment_files = []
|
||||||
for i, item in enumerate(timestamps_list):
|
for i, item in enumerate(timestamps_list):
|
||||||
@@ -920,7 +922,7 @@ def trim_video_method(media_file_path, timestamps_list):
|
|||||||
|
|
||||||
# For single timestamp, we can use the output file directly
|
# For single timestamp, we can use the output file directly
|
||||||
# For multiple timestamps, we need to create segment files
|
# For multiple timestamps, we need to create segment files
|
||||||
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4")
|
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}{input_ext}")
|
||||||
|
|
||||||
cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]
|
cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]
|
||||||
|
|
||||||
|
|||||||
@@ -494,7 +494,6 @@ def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
|
|||||||
state=helpers.get_default_state(user=original_media.user),
|
state=helpers.get_default_state(user=original_media.user),
|
||||||
is_reviewed=original_media.is_reviewed,
|
is_reviewed=original_media.is_reviewed,
|
||||||
encoding_status=original_media.encoding_status,
|
encoding_status=original_media.encoding_status,
|
||||||
listable=original_media.listable,
|
|
||||||
add_date=timezone.now(),
|
add_date=timezone.now(),
|
||||||
video_height=original_media.video_height,
|
video_height=original_media.video_height,
|
||||||
size=original_media.size,
|
size=original_media.size,
|
||||||
@@ -714,7 +713,6 @@ def copy_media(media):
|
|||||||
state=helpers.get_default_state(user=media.user),
|
state=helpers.get_default_state(user=media.user),
|
||||||
is_reviewed=media.is_reviewed,
|
is_reviewed=media.is_reviewed,
|
||||||
encoding_status=media.encoding_status,
|
encoding_status=media.encoding_status,
|
||||||
listable=media.listable,
|
|
||||||
add_date=timezone.now(),
|
add_date=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ class Media(models.Model):
|
|||||||
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
|
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
|
||||||
)
|
)
|
||||||
|
|
||||||
if transcription_changed and self.media_type == "video":
|
if transcription_changed and self.media_type in ["video", "audio"]:
|
||||||
self.transcribe_function()
|
self.transcribe_function()
|
||||||
|
|
||||||
# Update the original values for next comparison
|
# Update the original values for next comparison
|
||||||
@@ -329,10 +329,17 @@ class Media(models.Model):
|
|||||||
|
|
||||||
if to_transcribe:
|
if to_transcribe:
|
||||||
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
|
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
|
||||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False)
|
tasks.whisper_transcribe.apply_async(
|
||||||
|
args=[self.friendly_token, False],
|
||||||
|
countdown=10,
|
||||||
|
)
|
||||||
|
|
||||||
if to_transcribe_and_translate:
|
if to_transcribe_and_translate:
|
||||||
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
|
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
|
||||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True)
|
tasks.whisper_transcribe.apply_async(
|
||||||
|
args=[self.friendly_token, True],
|
||||||
|
countdown=10,
|
||||||
|
)
|
||||||
|
|
||||||
def update_search_vector(self):
|
def update_search_vector(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -26,18 +26,6 @@ const mediaPageLinkStyles = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
|
||||||
const parseTimeToSeconds = (timeString: string): number => {
|
|
||||||
const parts = timeString.split(':');
|
|
||||||
if (parts.length !== 3) return 0;
|
|
||||||
|
|
||||||
const hours = parseInt(parts[0], 10) || 0;
|
|
||||||
const minutes = parseInt(parts[1], 10) || 0;
|
|
||||||
const seconds = parseFloat(parts[2]) || 0;
|
|
||||||
|
|
||||||
return hours * 3600 + minutes * 60 + seconds;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TimelineControlsProps {
|
interface TimelineControlsProps {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
@@ -203,17 +191,7 @@ const TimelineControls = ({
|
|||||||
setIsAutoSaving(true);
|
setIsAutoSaving(true);
|
||||||
|
|
||||||
// Format segments data for API request - use ref to get latest segments and sort by start time
|
// Format segments data for API request - use ref to get latest segments and sort by start time
|
||||||
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
|
|
||||||
const chapters = clipSegmentsRef.current
|
const chapters = clipSegmentsRef.current
|
||||||
.filter((segment) => {
|
|
||||||
// Filter out empty titles
|
|
||||||
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
|
|
||||||
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
|
|
||||||
return !isDefaultName;
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
||||||
.map((chapter) => ({
|
.map((chapter) => ({
|
||||||
startTime: formatDetailedTime(chapter.startTime),
|
startTime: formatDetailedTime(chapter.startTime),
|
||||||
@@ -221,7 +199,7 @@ const TimelineControls = ({
|
|||||||
chapterTitle: chapter.chapterTitle,
|
chapterTitle: chapter.chapterTitle,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
logger.debug('Filtered chapters (only custom titles):', chapters);
|
logger.debug('chapters', chapters);
|
||||||
|
|
||||||
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
|
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
|
||||||
// For testing, use '1234' if no mediaId is available
|
// For testing, use '1234' if no mediaId is available
|
||||||
@@ -229,13 +207,12 @@ const TimelineControls = ({
|
|||||||
|
|
||||||
logger.debug('mediaId', finalMediaId);
|
logger.debug('mediaId', finalMediaId);
|
||||||
|
|
||||||
if (!finalMediaId) {
|
if (!finalMediaId || chapters.length === 0) {
|
||||||
logger.debug('No mediaId, skipping auto-save');
|
logger.debug('No mediaId or segments, skipping auto-save');
|
||||||
setIsAutoSaving(false);
|
setIsAutoSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save chapters (empty array if no chapters have titles)
|
|
||||||
logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters });
|
logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters });
|
||||||
|
|
||||||
const response = await autoSaveVideo(finalMediaId, { chapters });
|
const response = await autoSaveVideo(finalMediaId, { chapters });
|
||||||
@@ -291,13 +268,8 @@ const TimelineControls = ({
|
|||||||
// Update editing title when selected segment changes
|
// Update editing title when selected segment changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSegment) {
|
if (selectedSegment) {
|
||||||
// Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.)
|
// Always show the chapter title in the textarea, whether it's default or custom
|
||||||
const isDefaultChapterName = selectedSegment.chapterTitle &&
|
setEditingChapterTitle(selectedSegment.chapterTitle || '');
|
||||||
/^Chapter \d+$/.test(selectedSegment.chapterTitle);
|
|
||||||
|
|
||||||
// If it's a default name, show empty string so placeholder appears
|
|
||||||
// If it's a custom title, show the actual title
|
|
||||||
setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
|
|
||||||
} else {
|
} else {
|
||||||
setEditingChapterTitle('');
|
setEditingChapterTitle('');
|
||||||
}
|
}
|
||||||
@@ -522,20 +494,11 @@ const TimelineControls = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Format chapters data for API request - sort by start time first
|
// Format chapters data for API request - sort by start time first
|
||||||
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
|
|
||||||
const chapters = clipSegments
|
const chapters = clipSegments
|
||||||
.filter((segment) => {
|
.filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
|
||||||
// Filter out empty titles
|
|
||||||
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
|
|
||||||
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
|
|
||||||
return !isDefaultName;
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
||||||
.map((segment) => ({
|
.map((segment) => ({
|
||||||
chapterTitle: segment.chapterTitle,
|
chapterTitle: segment.chapterTitle || `Chapter ${segment.id}`,
|
||||||
from: formatDetailedTime(segment.startTime),
|
from: formatDetailedTime(segment.startTime),
|
||||||
to: formatDetailedTime(segment.endTime),
|
to: formatDetailedTime(segment.endTime),
|
||||||
}));
|
}));
|
||||||
@@ -4119,4 +4082,4 @@ const TimelineControls = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TimelineControls;
|
export default TimelineControls;
|
||||||
@@ -20,7 +20,7 @@ const useVideoChapters = () => {
|
|||||||
// Sort by start time to find chronological position
|
// Sort by start time to find chronological position
|
||||||
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
||||||
// Find the index of our new segment
|
// Find the index of our new segment
|
||||||
const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
|
const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime);
|
||||||
return `Chapter ${chapterIndex + 1}`;
|
return `Chapter ${chapterIndex + 1}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,12 +28,18 @@ const useVideoChapters = () => {
|
|||||||
const renumberAllSegments = (segments: Segment[]): Segment[] => {
|
const renumberAllSegments = (segments: Segment[]): Segment[] => {
|
||||||
// Sort segments by start time
|
// Sort segments by start time
|
||||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
// Renumber each segment based on its chronological position
|
// Renumber each segment based on its chronological position
|
||||||
return sortedSegments.map((segment, index) => ({
|
// Only update titles that follow the default "Chapter X" pattern to preserve custom titles
|
||||||
...segment,
|
return sortedSegments.map((segment, index) => {
|
||||||
chapterTitle: `Chapter ${index + 1}`
|
const currentTitle = segment.chapterTitle || '';
|
||||||
}));
|
const isDefaultTitle = /^Chapter \d+$/.test(currentTitle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...segment,
|
||||||
|
chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
||||||
@@ -124,9 +130,7 @@ const useVideoChapters = () => {
|
|||||||
let initialSegments: Segment[] = [];
|
let initialSegments: Segment[] = [];
|
||||||
|
|
||||||
// Check if we have existing chapters from the backend
|
// Check if we have existing chapters from the backend
|
||||||
const existingChapters =
|
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [];
|
||||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
|
|
||||||
[];
|
|
||||||
|
|
||||||
if (existingChapters.length > 0) {
|
if (existingChapters.length > 0) {
|
||||||
// Create segments from existing chapters
|
// Create segments from existing chapters
|
||||||
@@ -150,7 +154,7 @@ const useVideoChapters = () => {
|
|||||||
// Create a default segment that spans the entire video on first load
|
// Create a default segment that spans the entire video on first load
|
||||||
const initialSegment: Segment = {
|
const initialSegment: Segment = {
|
||||||
id: 1,
|
id: 1,
|
||||||
chapterTitle: '',
|
chapterTitle: 'Chapter 1',
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
endTime: video.duration,
|
endTime: video.duration,
|
||||||
};
|
};
|
||||||
@@ -225,7 +229,7 @@ const useVideoChapters = () => {
|
|||||||
logger.debug('Adding Safari-specific event listeners for audio support');
|
logger.debug('Adding Safari-specific event listeners for audio support');
|
||||||
video.addEventListener('canplay', handleCanPlay);
|
video.addEventListener('canplay', handleCanPlay);
|
||||||
video.addEventListener('loadeddata', handleLoadedData);
|
video.addEventListener('loadeddata', handleLoadedData);
|
||||||
|
|
||||||
// Additional timeout fallback for Safari audio files
|
// Additional timeout fallback for Safari audio files
|
||||||
const safariTimeout = setTimeout(() => {
|
const safariTimeout = setTimeout(() => {
|
||||||
if (video.duration && duration === 0) {
|
if (video.duration && duration === 0) {
|
||||||
@@ -261,21 +265,21 @@ const useVideoChapters = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSafari() && videoRef.current) {
|
if (isSafari() && videoRef.current) {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
|
|
||||||
const initializeSafariOnInteraction = () => {
|
const initializeSafariOnInteraction = () => {
|
||||||
// Try to load video metadata by attempting to play and immediately pause
|
// Try to load video metadata by attempting to play and immediately pause
|
||||||
const attemptInitialization = async () => {
|
const attemptInitialization = async () => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Safari: Attempting auto-initialization on user interaction');
|
logger.debug('Safari: Attempting auto-initialization on user interaction');
|
||||||
|
|
||||||
// Briefly play to trigger metadata loading, then pause
|
// Briefly play to trigger metadata loading, then pause
|
||||||
await video.play();
|
await video.play();
|
||||||
video.pause();
|
video.pause();
|
||||||
|
|
||||||
// Check if we now have duration and initialize if needed
|
// Check if we now have duration and initialize if needed
|
||||||
if (video.duration > 0 && clipSegments.length === 0) {
|
if (video.duration > 0 && clipSegments.length === 0) {
|
||||||
logger.debug('Safari: Successfully initialized metadata, creating default segment');
|
logger.debug('Safari: Successfully initialized metadata, creating default segment');
|
||||||
|
|
||||||
const defaultSegment: Segment = {
|
const defaultSegment: Segment = {
|
||||||
id: 1,
|
id: 1,
|
||||||
chapterTitle: '',
|
chapterTitle: '',
|
||||||
@@ -286,14 +290,14 @@ const useVideoChapters = () => {
|
|||||||
setDuration(video.duration);
|
setDuration(video.duration);
|
||||||
setTrimEnd(video.duration);
|
setTrimEnd(video.duration);
|
||||||
setClipSegments([defaultSegment]);
|
setClipSegments([defaultSegment]);
|
||||||
|
|
||||||
const initialState: EditorState = {
|
const initialState: EditorState = {
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: video.duration,
|
trimEnd: video.duration,
|
||||||
splitPoints: [],
|
splitPoints: [],
|
||||||
clipSegments: [defaultSegment],
|
clipSegments: [defaultSegment],
|
||||||
};
|
};
|
||||||
|
|
||||||
setHistory([initialState]);
|
setHistory([initialState]);
|
||||||
setHistoryPosition(0);
|
setHistoryPosition(0);
|
||||||
}
|
}
|
||||||
@@ -315,7 +319,7 @@ const useVideoChapters = () => {
|
|||||||
// Add listeners for various user interactions
|
// Add listeners for various user interactions
|
||||||
document.addEventListener('click', handleUserInteraction);
|
document.addEventListener('click', handleUserInteraction);
|
||||||
document.addEventListener('keydown', handleUserInteraction);
|
document.addEventListener('keydown', handleUserInteraction);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleUserInteraction);
|
document.removeEventListener('click', handleUserInteraction);
|
||||||
document.removeEventListener('keydown', handleUserInteraction);
|
document.removeEventListener('keydown', handleUserInteraction);
|
||||||
@@ -332,7 +336,7 @@ const useVideoChapters = () => {
|
|||||||
// This play/pause will trigger metadata loading in Safari
|
// This play/pause will trigger metadata loading in Safari
|
||||||
await video.play();
|
await video.play();
|
||||||
video.pause();
|
video.pause();
|
||||||
|
|
||||||
// The metadata events should fire now and initialize segments
|
// The metadata events should fire now and initialize segments
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -564,8 +568,11 @@ const useVideoChapters = () => {
|
|||||||
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
|
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Renumber all segments to ensure proper chronological naming
|
||||||
|
const renumberedSegments = renumberAllSegments(e.detail.segments);
|
||||||
|
|
||||||
// Update segment state immediately for UI feedback
|
// Update segment state immediately for UI feedback
|
||||||
setClipSegments(e.detail.segments);
|
setClipSegments(renumberedSegments);
|
||||||
|
|
||||||
// Always save state to history for non-intermediate actions
|
// Always save state to history for non-intermediate actions
|
||||||
if (isSignificantChange) {
|
if (isSignificantChange) {
|
||||||
@@ -573,7 +580,7 @@ const useVideoChapters = () => {
|
|||||||
// ensure we capture the state properly
|
// ensure we capture the state properly
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Deep clone to ensure state is captured correctly
|
// Deep clone to ensure state is captured correctly
|
||||||
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
|
const segmentsClone = JSON.parse(JSON.stringify(renumberedSegments));
|
||||||
|
|
||||||
// Create a complete state snapshot
|
// Create a complete state snapshot
|
||||||
const stateWithAction: EditorState = {
|
const stateWithAction: EditorState = {
|
||||||
@@ -919,10 +926,10 @@ const useVideoChapters = () => {
|
|||||||
const singleChapter = backendChapters[0];
|
const singleChapter = backendChapters[0];
|
||||||
const startSeconds = parseTimeToSeconds(singleChapter.startTime);
|
const startSeconds = parseTimeToSeconds(singleChapter.startTime);
|
||||||
const endSeconds = parseTimeToSeconds(singleChapter.endTime);
|
const endSeconds = parseTimeToSeconds(singleChapter.endTime);
|
||||||
|
|
||||||
// Check if this single chapter spans the entire video (within 0.1 second tolerance)
|
// Check if this single chapter spans the entire video (within 0.1 second tolerance)
|
||||||
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
|
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
|
||||||
|
|
||||||
if (isFullVideoChapter) {
|
if (isFullVideoChapter) {
|
||||||
logger.debug('Manual save: Single chapter spans full video - sending empty array');
|
logger.debug('Manual save: Single chapter spans full video - sending empty array');
|
||||||
backendChapters = [];
|
backendChapters = [];
|
||||||
|
|||||||
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal file
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useBulkActions } from '../hooks/useBulkActions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Higher-Order Component that provides bulk actions functionality
|
||||||
|
* to class components via props
|
||||||
|
*/
|
||||||
|
export function withBulkActions(WrappedComponent) {
|
||||||
|
return function WithBulkActionsComponent(props) {
|
||||||
|
const bulkActions = useBulkActions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WrappedComponent
|
||||||
|
{...props}
|
||||||
|
bulkActions={bulkActions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user