feat: Video Trimmer and more

This commit is contained in:
Yiannis Christodoulou
2025-05-15 10:43:26 +03:00
committed by GitHub
parent ab4d9d67df
commit a833b606f1
102 changed files with 13266 additions and 523 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
server/public
vite.config.ts.*
*.tar.gz
yt.readme.md
@@ -0,0 +1 @@
*
+132
View File
@@ -0,0 +1,132 @@
# MediaCMS Video Editor
A modern browser-based video editing tool built with React and TypeScript that integrates with MediaCMS. The editor allows users to trim, split, and manage video segments with a timeline interface.
## Features
- ⏱️ Trim video start and end points
- ✂️ Split videos into multiple segments
- 👁️ Preview individual segments or the full edited video
- 🔍 Zoom timeline for precise editing
- 🔄 Undo/redo support for all editing operations
- 🔊 Audio mute controls
- 💾 Save edits directly to MediaCMS
## Tech Stack
- React 18
- TypeScript
- Vite
- Tailwind CSS
- Express (for development server)
- Drizzle ORM
## Installation
### Prerequisites
- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
- Yarn or npm package manager
### Setup
```bash
# Navigate to the video editor directory
cd frontend-tools/video-editor
# Install dependencies with Yarn
yarn install
# Or with npm
npm install
```
## Development
The video editor can be run in two modes:
### Standalone Development Mode
This starts a local development server with hot reloading:
```bash
# Start the development server with Yarn
yarn dev
# Or with npm
npm run dev
```
### Frontend-only Development Mode
If you want to work only on the frontend with MediaCMS backend:
```bash
# Start frontend-only development with Yarn
yarn dev:frontend
# Or with npm
npm run dev:frontend
```
## Building
### For MediaCMS Integration
To build the video editor for integration with MediaCMS:
```bash
# Build for Django integration with Yarn
yarn build:django
# Or with npm
npm run build:django
```
This will compile the editor and place the output in the MediaCMS static directory.
### Standalone Build
To build the editor as a standalone application:
```bash
# Build for production with Yarn
yarn build
# Or with npm
npm run build
```
## Deployment
To deploy the video editor, you can use the build and deploy script (recommended):
```bash
# Run the deployment script
sh deploy/scripts/build_and_deploy.sh
```
The build script handles all necessary steps for compiling and deploying the editor to MediaCMS.
You can also deploy manually after building:
```bash
# With Yarn
yarn deploy
# Or with npm
npm run deploy
```
## Project Structure
- `/src` - Source code
- `/components` - React components
- `/hooks` - Custom React hooks
- `/lib` - Utility functions and helpers
- `/services` - API services
- `/styles` - CSS and style definitions
## API Integration
The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits.
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1"
/>
</head>
<body>
<div id="video-editor-trim-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,100 @@
import VideoPlayer from "@/components/VideoPlayer";
import TimelineControls from "@/components/TimelineControls";
import EditingTools from "@/components/EditingTools";
import ClipSegments from "@/components/ClipSegments";
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
const App = () => {
const {
videoRef,
currentTime,
duration,
isPlaying,
isPreviewMode,
isMuted,
thumbnails,
trimStart,
trimEnd,
splitPoints,
zoomLevel,
clipSegments,
historyPosition,
history,
hasUnsavedChanges,
playPauseVideo,
seekVideo,
handleTrimStartChange,
handleTrimEndChange,
handleSplit,
handleReset,
handleUndo,
handleRedo,
handlePreview,
handlePlay,
handleZoomChange,
toggleMute,
handleSave,
handleSaveACopy,
handleSaveSegments,
} = useVideoTrimmer();
return (
<div className="bg-background min-h-screen">
<div className="container mx-auto px-4 py-6 max-w-6xl">
{/* Video Player */}
<VideoPlayer
videoRef={videoRef}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
isMuted={isMuted}
onPlayPause={playPauseVideo}
onSeek={seekVideo}
onToggleMute={toggleMute}
/>
{/* Editing Tools */}
<EditingTools
onSplit={handleSplit}
onReset={handleReset}
onUndo={handleUndo}
onRedo={handleRedo}
onPreview={handlePreview}
onPlay={handlePlay}
isPreviewMode={isPreviewMode}
isPlaying={isPlaying}
canUndo={historyPosition > 0}
canRedo={historyPosition < history.length - 1}
/>
{/* Timeline Controls */}
<TimelineControls
currentTime={currentTime}
duration={duration}
thumbnails={thumbnails}
trimStart={trimStart}
trimEnd={trimEnd}
splitPoints={splitPoints}
zoomLevel={zoomLevel}
clipSegments={clipSegments}
onTrimStartChange={handleTrimStartChange}
onTrimEndChange={handleTrimEndChange}
onZoomChange={handleZoomChange}
onSeek={seekVideo}
videoRef={videoRef}
onSave={handleSave}
onSaveACopy={handleSaveACopy}
onSaveSegments={handleSaveSegments}
isPreviewMode={isPreviewMode}
hasUnsavedChanges={hasUnsavedChanges}
/>
{/* Clip Segments */}
<ClipSegments segments={clipSegments} />
</div>
</div>
);
};
export default App;
@@ -0,0 +1 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 832 B

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 813 B

@@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 597 B

@@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 611 B

@@ -0,0 +1,9 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<g data-name="1" id="_1">
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
<g transform="translate(6, 0)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 397 B

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>

After

Width:  |  Height:  |  Size: 359 B

@@ -0,0 +1,6 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<g data-name="1" id="_1">
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 364 B

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>

After

Width:  |  Height:  |  Size: 359 B

@@ -0,0 +1,86 @@
import { formatTime, formatLongTime } from "@/lib/timeUtils";
import '../styles/ClipSegments.css';
export interface Segment {
id: number;
name: string;
startTime: number;
endTime: number;
thumbnail: string;
}
interface ClipSegmentsProps {
segments: Segment[];
}
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
// Sort segments by startTime
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Handle delete segment click
const handleDeleteSegment = (segmentId: number) => {
// Create and dispatch the delete event
const deleteEvent = new CustomEvent('delete-segment', {
detail: { segmentId }
});
document.dispatchEvent(deleteEvent);
};
// Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 8
// This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 8) + 1}`;
};
return (
<div className="clip-segments-container">
<h3 className="clip-segments-title">Clip Segments</h3>
{sortedSegments.map((segment, index) => (
<div
key={segment.id}
className={`segment-item ${getSegmentColorClass(index)}`}
>
<div className="segment-content">
<div
className="segment-thumbnail"
style={{ backgroundImage: `url(${segment.thumbnail})` }}
></div>
<div className="segment-info">
<div className="segment-title">
Segment {index + 1}
</div>
<div className="segment-time">
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
</div>
<div className="segment-duration">
Duration: {formatLongTime(segment.endTime - segment.startTime)}
</div>
</div>
</div>
<div className="segment-actions">
<button
className="delete-button"
aria-label="Delete Segment"
data-tooltip="Delete this segment"
onClick={() => handleDeleteSegment(segment.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
))}
{sortedSegments.length === 0 && (
<div className="empty-message">
No segments created yet. Use the split button to create segments.
</div>
)}
</div>
);
};
export default ClipSegments;
@@ -0,0 +1,151 @@
import '../styles/EditingTools.css';
interface EditingToolsProps {
onSplit: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onPreview: () => void;
onPlay: () => void;
canUndo: boolean;
canRedo: boolean;
isPreviewMode?: boolean;
isPlaying?: boolean;
}
const EditingTools = ({
onSplit,
onReset,
onUndo,
onRedo,
onPreview,
onPlay,
canUndo,
canRedo,
isPreviewMode = false,
isPlaying = false,
}: EditingToolsProps) => {
return (
<div className="editing-tools-container">
<div className="flex-container single-row">
{/* Left side - Play buttons group */}
<div className="button-group play-buttons-group">
{/* Play Preview button */}
<button
className="button preview-button"
onClick={onPreview}
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
style={{ fontSize: '0.875rem' }}
>
{isPreviewMode ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Stop Preview</span>
<span className="short-text">Stop</span>
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play Preview</span>
<span className="short-text">Preview</span>
</>
)}
</button>
{/* Standard Play button (only shown when not in preview mode) */}
{!isPreviewMode && (
<button
className="button play-button"
onClick={onPlay}
data-tooltip={isPlaying ? "Pause video" : "Play full video"}
style={{ fontSize: '0.875rem' }}
>
{isPlaying ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Pause</span>
<span className="short-text">Pause</span>
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play</span>
<span className="short-text">Play</span>
</>
)}
</button>
)}
{/* Preview mode message (replaces play button) */}
{isPreviewMode && (
<div className="preview-mode-message">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="8" />
</svg>
Preview Mode
</div>
)}
</div>
{/* Right side - Editing tools */}
<div className="button-group secondary">
<button
className="button"
aria-label="Undo"
data-tooltip="Undo last action"
disabled={!canUndo}
onClick={onUndo}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 14 4 9l5-5"/>
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>
</svg>
<span className="button-text">Undo</span>
</button>
<button
className="button"
aria-label="Redo"
data-tooltip="Redo last undone action"
disabled={!canRedo}
onClick={onRedo}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 14 5-5-5-5"/>
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/>
</svg>
<span className="button-text">Redo</span>
</button>
<div className="divider"></div>
<button
className="button"
onClick={onReset}
data-tooltip="Reset to full video"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
<span className="reset-text">Reset</span>
</button>
</div>
</div>
</div>
);
};
export default EditingTools;
@@ -0,0 +1,90 @@
import React, { useEffect } from 'react';
import '../styles/Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
actions?: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
actions
}) => {
// Close modal when Escape key is pressed
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscapeKey);
// Disable body scrolling when modal is open
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscapeKey);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
// Handle click outside the modal content to close it
const handleClickOutside = (event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleClickOutside}>
<div className="modal-container" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{title}</h2>
<button
className="modal-close-button"
onClick={onClose}
aria-label="Close modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="modal-content">
{children}
</div>
{actions && (
<div className="modal-actions">
{actions}
</div>
)}
</div>
</div>
);
};
export default Modal;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,163 @@
import { useRef, useEffect } from "react";
import { formatTime } from "@/lib/timeUtils";
import '../styles/VideoPlayer.css';
interface VideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number;
duration: number;
isPlaying: boolean;
isMuted?: boolean;
onPlayPause: () => void;
onSeek: (time: number) => void;
onToggleMute?: () => void;
}
const VideoPlayer = ({
videoRef,
currentTime,
duration,
isPlaying,
isMuted = false,
onPlayPause,
onSeek,
onToggleMute
}: VideoPlayerProps) => {
const progressRef = useRef<HTMLDivElement>(null);
const sampleVideoUrl = typeof window !== 'undefined' &&
(window as any).MEDIA_DATA?.videoUrl ||
"https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
// Add iOS-specific attributes to prevent fullscreen playback
useEffect(() => {
if (videoRef.current) {
// These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback
videoRef.current.setAttribute('playsinline', 'true');
videoRef.current.setAttribute('webkit-playsinline', 'true');
videoRef.current.setAttribute('x-webkit-airplay', 'allow');
}
}, [videoRef]);
// Jump 10 seconds forward
const handleForward = () => {
onSeek(Math.min(currentTime + 10, duration));
};
// Jump 10 seconds backward
const handleBackward = () => {
onSeek(Math.max(currentTime - 10, 0));
};
// Calculate progress percentage
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
// Handle click on progress bar
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (progressRef.current) {
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = (e.clientX - rect.left) / rect.width;
onSeek(duration * clickPosition);
}
};
// Handle toggling fullscreen
const handleFullscreen = () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
};
return (
<div className="video-player-container">
<video
ref={videoRef}
preload="auto"
crossOrigin="anonymous"
onClick={onPlayPause}
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
controls={false}
muted={isMuted}
>
<source src={sampleVideoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
{/* Video Controls Overlay */}
<div className="video-controls">
{/* Time and Duration */}
<div className="video-time-display">
<span className="video-current-time">{formatTime(currentTime)}</span>
<span className="video-duration">/ {formatTime(duration)}</span>
</div>
{/* Progress Bar */}
<div
ref={progressRef}
className="video-progress"
onClick={handleProgressClick}
>
<div
className="video-progress-fill"
style={{ width: `${progressPercentage}%` }}
></div>
<div
className="video-scrubber"
style={{ left: `${progressPercentage}%` }}
></div>
</div>
{/* Controls - Mute and Fullscreen buttons */}
<div className="video-controls-buttons">
{/* Mute/Unmute Button */}
{onToggleMute && (
<button
className="mute-button"
aria-label={isMuted ? "Unmute" : "Mute"}
onClick={onToggleMute}
data-tooltip={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
)}
</button>
)}
{/* Fullscreen Button */}
<button
className="fullscreen-button"
aria-label="Fullscreen"
onClick={handleFullscreen}
data-tooltip="Toggle fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
);
};
export default VideoPlayer;
@@ -0,0 +1,931 @@
import { useState, useRef, useEffect } from "react";
import { generateThumbnail } from "@/lib/videoUtils";
import { formatDetailedTime } from "@/lib/timeUtils";
import logger from "@/lib/logger";
import type { Segment } from "@/components/ClipSegments";
// Represents a state of the editor for undo/redo
interface EditorState {
trimStart: number;
trimEnd: number;
splitPoints: number[];
clipSegments: Segment[];
action?: string;
}
const useVideoTrimmer = () => {
// Video element reference and state
const videoRef = useRef<HTMLVideoElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
// Preview mode state for playing only segments
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [previewSegmentIndex, setPreviewSegmentIndex] = useState(0);
// Timeline state
const [thumbnails, setThumbnails] = useState<string[]>([]);
const [trimStart, setTrimStart] = useState(0);
const [trimEnd, setTrimEnd] = useState(0);
const [splitPoints, setSplitPoints] = useState<number[]>([]);
const [zoomLevel, setZoomLevel] = useState(1); // Start with 1x zoom level
// Clip segments state
const [clipSegments, setClipSegments] = useState<Segment[]>([]);
// History state for undo/redo
const [history, setHistory] = useState<EditorState[]>([]);
const [historyPosition, setHistoryPosition] = useState(-1);
// Track unsaved changes
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Monitor for history changes
useEffect(() => {
if (history.length > 0) {
// For debugging - moved to console.debug
if (process.env.NODE_ENV === 'development') {
console.debug(`History state updated: ${history.length} entries, position: ${historyPosition}`);
// Log actions in history to help debug undo/redo
const actions = history.map((state, idx) =>
`${idx}: ${state.action || 'unknown'} (segments: ${state.clipSegments.length})`
);
console.debug('History actions:', actions);
}
// If there's at least one history entry and it wasn't a save operation, mark as having unsaved changes
const lastAction = history[historyPosition]?.action || '';
if (lastAction !== 'save' && lastAction !== 'save_copy' && lastAction !== 'save_segments') {
setHasUnsavedChanges(true);
}
}
}, [history, historyPosition]);
// Set up page unload warning
useEffect(() => {
// Event handler for beforeunload
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
// Standard way of showing a confirmation dialog before leaving
const message = 'Your edits will get lost if you leave the page. Do you want to continue?';
e.preventDefault();
e.returnValue = message; // Chrome requires returnValue to be set
return message; // For other browsers
}
};
// Add event listener
window.addEventListener('beforeunload', handleBeforeUnload);
// Clean up
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [hasUnsavedChanges]);
// Initialize video event listeners
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleLoadedMetadata = () => {
setDuration(video.duration);
setTrimEnd(video.duration);
// Generate placeholders and create initial segment
const initializeEditor = async () => {
// Generate thumbnail for initial segment
const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
// Create an initial segment that spans the entire video
const initialSegment: Segment = {
id: 1,
name: "segment",
startTime: 0,
endTime: video.duration,
thumbnail: segmentThumbnail
};
// Initialize history state with the full-length segment
const initialState: EditorState = {
trimStart: 0,
trimEnd: video.duration,
splitPoints: [],
clipSegments: [initialSegment]
};
setHistory([initialState]);
setHistoryPosition(0);
setClipSegments([initialSegment]);
// Generate timeline thumbnails
const count = 6;
const interval = video.duration / count;
const placeholders: string[] = [];
for (let i = 0; i < count; i++) {
const time = interval * i + interval / 2;
const thumbnail = await generateThumbnail(video, time);
placeholders.push(thumbnail);
}
setThumbnails(placeholders);
};
initializeEditor();
};
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
};
const handlePlay = () => {
// Only update isPlaying if we're not in preview mode
if (!isPreviewMode) {
setIsPlaying(true);
}
};
const handlePause = () => {
// Only update isPlaying if we're not in preview mode
if (!isPreviewMode) {
setIsPlaying(false);
}
};
const handleEnded = () => {
setIsPlaying(false);
video.currentTime = trimStart;
};
// Add event listeners
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handleEnded);
return () => {
// Remove event listeners
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
};
}, [isPreviewMode]);
// Play/pause video
const playPauseVideo = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
// If at the end of the trim range, reset to the beginning
if (video.currentTime >= trimEnd) {
video.currentTime = trimStart;
}
video.play()
.then(() => {
// Play started successfully
})
.catch(err => {
console.error("Error starting playback:", err);
setIsPlaying(false); // Reset state if play failed
});
}
};
// Seek to a specific time
const seekVideo = (time: number) => {
const video = videoRef.current;
if (!video) return;
// Track if the video was playing before seeking
const wasPlaying = !video.paused;
// Store current preview mode state to preserve it
const wasInPreviewMode = isPreviewMode;
// Update the video position
video.currentTime = time;
setCurrentTime(time);
// Find segment at this position for preview mode playback
if (wasInPreviewMode) {
const segmentAtPosition = clipSegments.find(
seg => time >= seg.startTime && time <= seg.endTime
);
if (segmentAtPosition) {
// Update the active segment index in preview mode
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const newSegmentIndex = orderedSegments.findIndex(seg => seg.id === segmentAtPosition.id);
if (newSegmentIndex !== -1) {
setPreviewSegmentIndex(newSegmentIndex);
}
}
}
// Resume playback in two scenarios:
// 1. If it was playing before (regular mode)
// 2. If we're in preview mode (regardless of previous state)
if (wasPlaying || wasInPreviewMode) {
// Ensure preview mode stays on if it was on before
if (wasInPreviewMode) {
setIsPreviewMode(true);
}
// Play immediately without delay
video.play()
.then(() => {
setIsPlaying(true); // Update state to reflect we're playing
// "Resumed playback after seeking in " + (wasInPreviewMode ? "preview" : "regular") + " mode"
})
.catch(err => {
console.error("Error resuming playback:", err);
setIsPlaying(false);
});
}
};
// Save the current state to history with a debounce buffer
// This helps prevent multiple rapid saves for small adjustments
const saveState = (action?: string) => {
// Deep clone to ensure state is captured correctly
const newState: EditorState = {
trimStart,
trimEnd,
splitPoints: [...splitPoints],
clipSegments: JSON.parse(JSON.stringify(clipSegments)), // Deep clone to avoid reference issues
action: action || 'manual_save' // Track the action that triggered this save
};
// Check if state is significantly different from last saved state
const lastState = history[historyPosition];
// Helper function to compare segments deeply
const haveSegmentsChanged = () => {
if (!lastState || lastState.clipSegments.length !== newState.clipSegments.length) {
return true; // Different length means significant change
}
// Compare each segment's start and end times
for (let i = 0; i < newState.clipSegments.length; i++) {
const oldSeg = lastState.clipSegments[i];
const newSeg = newState.clipSegments[i];
if (!oldSeg || !newSeg) return true;
// Check if any time values changed by more than 0.001 seconds (1ms)
if (Math.abs(oldSeg.startTime - newSeg.startTime) > 0.001 ||
Math.abs(oldSeg.endTime - newSeg.endTime) > 0.001) {
return true;
}
}
return false; // No significant changes found
};
const isSignificantChange = !lastState ||
lastState.trimStart !== newState.trimStart ||
lastState.trimEnd !== newState.trimEnd ||
lastState.splitPoints.length !== newState.splitPoints.length ||
haveSegmentsChanged();
// Additionally, check if there's an explicit action from a UI event
const hasExplicitActionFlag = newState.action !== undefined;
// Only proceed if this is a significant change or if explicitly requested
if (isSignificantChange || hasExplicitActionFlag) {
// Get the current position to avoid closure issues
const currentPosition = historyPosition;
// Use functional updates to ensure we're working with the latest state
setHistory(prevHistory => {
// If we're not at the end of history, truncate
if (currentPosition < prevHistory.length - 1) {
const newHistory = prevHistory.slice(0, currentPosition + 1);
return [...newHistory, newState];
} else {
// Just append to current history
return [...prevHistory, newState];
}
});
// Update position using functional update
setHistoryPosition(prev => {
const newPosition = prev + 1;
// "Saved state to history position", newPosition)
return newPosition;
});
} else {
// logger.debug("Skipped non-significant state save");
}
};
// Listen for trim handle update events
useEffect(() => {
const handleTrimUpdate = (e: CustomEvent) => {
if (e.detail) {
const { time, isStart, recordHistory, action } = e.detail;
if (isStart) {
setTrimStart(time);
} else {
setTrimEnd(time);
}
// Only record in history if explicitly requested
if (recordHistory) {
// Use a small timeout to ensure the state is updated
setTimeout(() => {
saveState(action || (isStart ? 'adjust_trim_start' : 'adjust_trim_end'));
}, 10);
}
}
};
document.addEventListener('update-trim', handleTrimUpdate as EventListener);
return () => {
document.removeEventListener('update-trim', handleTrimUpdate as EventListener);
};
}, []);
// Listen for segment update events and split-at-time events
useEffect(() => {
const handleUpdateSegments = (e: CustomEvent) => {
if (e.detail && e.detail.segments) {
// Check if this is a significant change that should be recorded in history
// Default to true to ensure all segment changes are recorded
const isSignificantChange = e.detail.recordHistory !== false;
// Get the action type if provided
const actionType = e.detail.action || 'update_segments';
// Log the update details
logger.debug(`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? "true" : "false"}`);
// Update segment state immediately for UI feedback
setClipSegments(e.detail.segments);
// Always save state to history for non-intermediate actions
if (isSignificantChange) {
// A slight delay helps avoid race conditions but we need to
// ensure we capture the state properly
setTimeout(() => {
// Deep clone to ensure state is captured correctly
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
// Create a complete state snapshot
const stateWithAction: EditorState = {
trimStart,
trimEnd,
splitPoints: [...splitPoints],
clipSegments: segmentsClone,
action: actionType // Store the action type in the state
};
// Get the current history position to ensure we're using the latest value
const currentHistoryPosition = historyPosition;
// Update history with the functional pattern to avoid stale closure issues
setHistory(prevHistory => {
// If we're not at the end of the history, truncate
if (currentHistoryPosition < prevHistory.length - 1) {
const newHistory = prevHistory.slice(0, currentHistoryPosition + 1);
return [...newHistory, stateWithAction];
} else {
// Just append to current history
return [...prevHistory, stateWithAction];
}
});
// Ensure the historyPosition is updated to the correct position
setHistoryPosition(prev => {
const newPosition = prev + 1;
logger.debug(`Saved state with action: ${actionType} to history position ${newPosition}`);
return newPosition;
});
}, 20); // Slightly increased delay to ensure state updates are complete
} else {
logger.debug(`Skipped saving state to history for action: ${actionType} (recordHistory=false)`);
}
}
};
const handleSplitSegment = async (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail &&
typeof customEvent.detail.time === 'number' &&
typeof customEvent.detail.segmentId === 'number') {
// Get the time and segment ID from the event
const timeToSplit = customEvent.detail.time;
const segmentId = customEvent.detail.segmentId;
// Move the current time to the split position
seekVideo(timeToSplit);
// Find the segment to split
const segmentToSplit = clipSegments.find(seg => seg.id === segmentId);
if (!segmentToSplit) return;
// Make sure the split point is within the segment
if (timeToSplit <= segmentToSplit.startTime || timeToSplit >= segmentToSplit.endTime) {
return; // Can't split outside segment boundaries
}
// Create two new segments from the split
const newSegments = [...clipSegments];
// Remove the original segment
const segmentIndex = newSegments.findIndex(seg => seg.id === segmentId);
if (segmentIndex === -1) return;
newSegments.splice(segmentIndex, 1);
// Create first half of the split segment - no thumbnail needed
const firstHalf: Segment = {
id: Date.now(),
name: `${segmentToSplit.name}-A`,
startTime: segmentToSplit.startTime,
endTime: timeToSplit,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Create second half of the split segment - no thumbnail needed
const secondHalf: Segment = {
id: Date.now() + 1,
name: `${segmentToSplit.name}-B`,
startTime: timeToSplit,
endTime: segmentToSplit.endTime,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Add the new segments
newSegments.push(firstHalf, secondHalf);
// Sort segments by start time
newSegments.sort((a, b) => a.startTime - b.startTime);
// Update state
setClipSegments(newSegments);
saveState('split_segment');
}
};
// Handle delete segment event
const handleDeleteSegment = async (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail && typeof customEvent.detail.segmentId === 'number') {
const segmentId = customEvent.detail.segmentId;
// Find and remove the segment
const newSegments = clipSegments.filter(segment => segment.id !== segmentId);
if (newSegments.length !== clipSegments.length) {
// If all segments are deleted, create a new full video segment
if (newSegments.length === 0 && videoRef.current) {
// Create a new default segment that spans the entire video
// No need to generate a thumbnail - we'll use dynamic colors
const defaultSegment: Segment = {
id: Date.now(),
name: "segment",
startTime: 0,
endTime: videoRef.current.duration,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Reset the trim points as well
setTrimStart(0);
setTrimEnd(videoRef.current.duration);
setSplitPoints([]);
setClipSegments([defaultSegment]);
} else {
// Just update the segments normally
setClipSegments(newSegments);
}
saveState('delete_segment');
}
}
};
document.addEventListener('update-segments', handleUpdateSegments as EventListener);
document.addEventListener('split-segment', handleSplitSegment as EventListener);
document.addEventListener('delete-segment', handleDeleteSegment as EventListener);
return () => {
document.removeEventListener('update-segments', handleUpdateSegments as EventListener);
document.removeEventListener('split-segment', handleSplitSegment as EventListener);
document.removeEventListener('delete-segment', handleDeleteSegment as EventListener);
};
}, [clipSegments, duration]);
// Preview mode effect to handle playing only segments
useEffect(() => {
if (!isPreviewMode || !videoRef.current) return;
// logger.debug("Preview mode effect running, previewSegmentIndex:", previewSegmentIndex);
// Sort segments by start time
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Check if we've reached the end of the current segment
const handleSegmentPlayback = () => {
if (!isPreviewMode || !videoRef.current) return;
const currentSegment = orderedSegments[previewSegmentIndex];
if (!currentSegment) return;
// Make sure we're inside the current segment
const currentTime = videoRef.current.currentTime;
// If the current time is before the segment start, move to the segment start
if (currentTime < currentSegment.startTime) {
videoRef.current.currentTime = currentSegment.startTime;
return;
}
// If we've reached the end of the current segment
if (currentTime >= currentSegment.endTime - 0.05) { // Slightly before the end to ensure smooth transition
// Move to the next segment if available
if (previewSegmentIndex < orderedSegments.length - 1) {
const nextSegment = orderedSegments[previewSegmentIndex + 1];
// Play through the next segment immediately
videoRef.current.currentTime = nextSegment.startTime;
setPreviewSegmentIndex(previewSegmentIndex + 1);
logger.debug("Moving to next segment in preview:", nextSegment.name,
"from", formatDetailedTime(currentSegment.endTime),
"to", formatDetailedTime(nextSegment.startTime));
// Ensure playback continues
videoRef.current.play().catch(err => {
console.error("Error continuing preview playback:", err);
});
} else {
// End of all segments, loop back to the first segment
logger.debug("End of all segments in preview, looping back to first");
videoRef.current.currentTime = orderedSegments[0].startTime;
setPreviewSegmentIndex(0);
// Ensure playback continues
videoRef.current.play().catch(err => {
console.error("Error restarting preview playback:", err);
});
}
}
};
// Add event listener for timeupdate to check segment boundaries
videoRef.current.addEventListener('timeupdate', handleSegmentPlayback);
return () => {
if (videoRef.current) {
videoRef.current.removeEventListener('timeupdate', handleSegmentPlayback);
}
};
}, [isPreviewMode, previewSegmentIndex, clipSegments]);
// Handle trim start change
const handleTrimStartChange = (time: number) => {
setTrimStart(time);
saveState('adjust_trim_start');
};
// Handle trim end change
const handleTrimEndChange = (time: number) => {
setTrimEnd(time);
saveState('adjust_trim_end');
};
// Handle split at current position
const handleSplit = async () => {
if (!videoRef.current) return;
// Add current time to split points if not already present
if (!splitPoints.includes(currentTime)) {
const newSplitPoints = [...splitPoints, currentTime].sort((a, b) => a - b);
setSplitPoints(newSplitPoints);
// Generate segments based on split points
const newSegments: Segment[] = [];
let startTime = 0;
for (let i = 0; i <= newSplitPoints.length; i++) {
const endTime = i < newSplitPoints.length ? newSplitPoints[i] : duration;
if (startTime < endTime) {
// No need to generate thumbnails - we'll use dynamic colors
newSegments.push({
id: Date.now() + i,
name: `Segment ${i + 1}`,
startTime,
endTime,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
});
startTime = endTime;
}
}
setClipSegments(newSegments);
saveState('create_split_points');
}
};
// Handle reset of all edits
const handleReset = async () => {
setTrimStart(0);
setTrimEnd(duration);
setSplitPoints([]);
// Create a new default segment that spans the entire video
if (!videoRef.current) return;
// No need to generate thumbnails - we'll use dynamic colors
const defaultSegment: Segment = {
id: Date.now(),
name: "segment",
startTime: 0,
endTime: duration,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
setClipSegments([defaultSegment]);
saveState('reset_all');
};
// Handle undo
const handleUndo = () => {
if (historyPosition > 0) {
const previousState = history[historyPosition - 1];
logger.debug(`** UNDO ** to position ${historyPosition - 1}, action: ${previousState.action}, segments: ${previousState.clipSegments.length}`);
// Log segment details to help debug
logger.debug("Segment details after undo:", previousState.clipSegments.map(seg =>
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
));
// Apply the previous state with deep cloning to avoid reference issues
setTrimStart(previousState.trimStart);
setTrimEnd(previousState.trimEnd);
setSplitPoints([...previousState.splitPoints]);
setClipSegments(JSON.parse(JSON.stringify(previousState.clipSegments)));
setHistoryPosition(historyPosition - 1);
} else {
logger.debug("Cannot undo: at earliest history position");
}
};
// Handle redo
const handleRedo = () => {
if (historyPosition < history.length - 1) {
const nextState = history[historyPosition + 1];
logger.debug(`** REDO ** to position ${historyPosition + 1}, action: ${nextState.action}, segments: ${nextState.clipSegments.length}`);
// Log segment details to help debug
logger.debug("Segment details after redo:", nextState.clipSegments.map(seg =>
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
));
// Apply the next state with deep cloning to avoid reference issues
setTrimStart(nextState.trimStart);
setTrimEnd(nextState.trimEnd);
setSplitPoints([...nextState.splitPoints]);
setClipSegments(JSON.parse(JSON.stringify(nextState.clipSegments)));
setHistoryPosition(historyPosition + 1);
} else {
logger.debug("Cannot redo: at latest history position");
}
};
// Handle playing the preview of all segments
const handlePreview = () => {
const video = videoRef.current;
if (!video || clipSegments.length === 0) return;
// If preview is already active, turn it off
if (isPreviewMode) {
setIsPreviewMode(false);
// Always pause the video when exiting preview mode
video.pause();
setIsPlaying(false);
logger.debug("Exiting preview mode - video paused");
return;
}
// If normal playback is happening, remember that state but pause it
const wasPlaying = isPlaying;
if (wasPlaying) {
video.pause();
}
// Sort segments by start time to ensure proper playback order
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
if (orderedSegments.length === 0) return;
// Set the preview mode flag before starting playback
setIsPreviewMode(true);
logger.debug("Entering preview mode");
// Set the first segment as the current one in the preview sequence
setPreviewSegmentIndex(0);
// Start preview mode by playing the first segment
video.currentTime = orderedSegments[0].startTime;
// Force a small delay before playing to ensure the preview mode state is set
setTimeout(() => {
if (videoRef.current) {
videoRef.current.play()
.then(() => {
logger.debug("Preview started successfully");
setIsPlaying(true);
})
.catch(err => {
console.error("Error starting preview:", err);
setIsPreviewMode(false); // Reset preview mode if play fails
setIsPlaying(false);
});
}
}, 50); // Short delay to ensure state updates have processed
};
// Handle zoom level change
const handleZoomChange = (level: number) => {
setZoomLevel(level);
};
// Handle play/pause of the full video
const handlePlay = () => {
const video = videoRef.current;
if (!video) return;
// If in preview mode, exit it before toggling normal play
if (isPreviewMode) {
setIsPreviewMode(false);
// Don't immediately start playing when exiting preview mode
// Just update the state and return
setIsPlaying(false);
video.pause();
return;
}
if (isPlaying) {
// Pause the video
video.pause();
setIsPlaying(false);
} else {
// Play the video from current position with proper promise handling
video.play()
.then(() => {
setIsPlaying(true);
})
.catch(err => {
console.error("Error playing video:", err);
setIsPlaying(false); // Reset state if play failed
});
}
};
// Toggle mute state
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(!isMuted);
};
// Handle save action
const handleSave = () => {
// Sort segments chronologically by start time before saving
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Create the JSON data for saving
const saveData = {
type: "save",
segments: sortedSegments.map(segment => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime)
}))
};
// Display JSON in alert (for demonstration purposes)
alert(JSON.stringify(saveData, null, 2));
// Mark as saved - no unsaved changes
setHasUnsavedChanges(false);
// Debug message
if (process.env.NODE_ENV === 'development') {
console.debug("Changes saved - reset unsaved changes flag");
}
// Save to history with special "save" action to mark saved state
saveState('save');
// In a real implementation, this would make a POST request to save the data
// logger.debug("Save data:", saveData);
};
// Handle save a copy action
const handleSaveACopy = () => {
// Sort segments chronologically by start time before saving
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Create the JSON data for saving as a copy
const saveData = {
type: "save_as_a_copy",
segments: sortedSegments.map(segment => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime)
}))
};
// Display JSON in alert (for demonstration purposes)
alert(JSON.stringify(saveData, null, 2));
// Mark as saved - no unsaved changes
setHasUnsavedChanges(false);
// Debug message
if (process.env.NODE_ENV === 'development') {
console.debug("Changes saved as copy - reset unsaved changes flag");
}
// Save to history with special "save_copy" action to mark saved state
saveState('save_copy');
};
// Handle save segments individually action
const handleSaveSegments = () => {
// Sort segments chronologically by start time before saving
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Create the JSON data for saving individual segments
const saveData = {
type: "save_segments",
segments: sortedSegments.map(segment => ({
name: segment.name,
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime)
}))
};
// Display JSON in alert (for demonstration purposes)
alert(JSON.stringify(saveData, null, 2));
// Mark as saved - no unsaved changes
setHasUnsavedChanges(false);
// Debug message
logger.debug("All segments saved individually - reset unsaved changes flag");
// Save to history with special "save_segments" action to mark saved state
saveState('save_segments');
};
return {
videoRef,
currentTime,
duration,
isPlaying,
isPreviewMode,
thumbnails,
trimStart,
trimEnd,
splitPoints,
zoomLevel,
clipSegments,
historyPosition,
history,
isMuted,
hasUnsavedChanges, // Add unsaved changes flag to the return object
playPauseVideo,
seekVideo,
handleTrimStartChange,
handleTrimEndChange,
handleSplit,
handleReset,
handleUndo,
handleRedo,
handlePreview,
handlePlay,
handleZoomChange,
toggleMute,
handleSave,
handleSaveACopy,
handleSaveSegments,
};
};
export default useVideoTrimmer;
@@ -0,0 +1,786 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground: 20 14.3% 4.1%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--primary: 207 90% 54%;
--primary-foreground: 211 100% 99%;
--secondary: 30 84% 54%; /* Changed from red (0) to orange (30) */
--secondary-foreground: 60 9.1% 97.8%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
}
@layer base {
* {
@apply border-border;
}
}
/* Video Player Styles */
.video-player {
position: relative;
width: 100%;
background-color: #000;
overflow: hidden;
border-radius: 0.5rem;
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
padding: 1rem;
display: flex;
flex-direction: column;
}
.video-current-time {
color: #fff;
font-weight: 500;
}
.video-progress {
position: relative;
height: 4px;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 2px;
margin-bottom: 1rem;
}
.video-progress-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: hsl(var(--primary));
border-radius: 2px;
}
.video-scrubber {
position: absolute;
width: 12px;
height: 12px;
margin-left: -6px;
background-color: white;
border-radius: 50%;
top: -4px;
}
/* Play/Pause indicator for video player */
.video-player-container {
position: relative;
overflow: hidden;
}
.play-pause-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70px;
height: 70px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 20;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
background-position: center;
background-repeat: no-repeat;
}
.play-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E");
}
.pause-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E");
}
/* Only show play/pause indicator on hover */
.video-player-container:hover .play-pause-indicator {
opacity: 1;
}
/* Timeline Styles */
.timeline-scroll-container {
height: 6rem;
border-radius: 0.375rem;
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 0.75rem;
background-color: #EEE; /* Very light gray background */
position: relative;
}
.timeline-container {
position: relative;
background-color: #EEE; /* Very light gray background */
height: 6rem;
width: 100%;
cursor: pointer;
transition: width 0.3s ease;
}
.timeline-marker {
position: absolute;
top: -10px;
height: calc(100% + 10px);
width: 2px;
background-color: red;
z-index: 100; /* Highest z-index to stay on top of everything */
pointer-events: none; /* Allow clicks to pass through to segments underneath */
box-shadow: 0 0 4px rgba(255, 0, 0, 0.5); /* Add subtle glow effect */
}
.trim-line-marker {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
z-index: 10;
}
.trim-handle {
width: 8px;
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
position: absolute;
top: 0;
bottom: 0;
cursor: ew-resize;
z-index: 15;
}
.trim-handle.left {
left: -4px;
}
.trim-handle.right {
right: -4px;
}
.timeline-thumbnail {
height: 100%;
border-right: 1px solid rgba(0, 0, 0, 0.1);
position: relative;
display: inline-block;
background-size: cover;
background-position: center;
}
.split-point {
position: absolute;
width: 2px;
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
top: 0;
bottom: 0;
z-index: 5;
}
/* Clip Segment styles */
.clip-segment {
position: absolute;
height: 95%;
top: 0;
border-radius: 4px;
background-size: cover;
background-position: center;
background-blend-mode: soft-light;
/* Border is now set in the color-specific rules */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
cursor: grab;
user-select: none;
transition: box-shadow 0.2s, transform 0.1s;
/* Original z-index for stacking order based on segment ID */
z-index: 15;
}
/* No background colors for segments, just borders with 2-color scheme */
.clip-segment:nth-child(odd), .segment-color-1, .segment-color-3, .segment-color-5, .segment-color-7 {
background-color: transparent;
border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */
}
.clip-segment:nth-child(even), .segment-color-2, .segment-color-4, .segment-color-6, .segment-color-8 {
background-color: transparent;
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
}
.clip-segment:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transform: translateY(-1px);
filter: brightness(1.1);
}
.clip-segment:active {
cursor: grabbing;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transform: translateY(0);
}
.clip-segment.selected {
border-width: 3px; /* Make border thicker instead of changing color */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 25;
filter: brightness(1.2);
}
.clip-segment-info {
background-color: rgba(226, 230, 234, 0.9); /* Light gray background */
color: #000000; /* Pure black text */
padding: 6px 8px;
font-size: 0.7rem;
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 4px 4px 0 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 2px;
}
.clip-segment-name {
font-weight: bold;
color: #000000; /* Pure black text */
}
.clip-segment-time {
font-size: 0.65rem;
color: #000000; /* Pure black text */
}
.clip-segment-duration {
font-size: 0.65rem;
color: #000000; /* Pure black text */
background: rgba(179, 217, 255, 0.4); /* Light blue background */
padding: 1px 4px;
border-radius: 2px;
display: inline-block;
margin-top: 2px;
}
.clip-segment-handle {
position: absolute;
width: 8px;
top: 0;
bottom: 0;
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
cursor: ew-resize;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
}
.clip-segment-handle::after {
content: "↔";
color: white;
font-size: 12px;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
}
.clip-segment-handle.left {
left: 0;
}
.clip-segment-handle.right {
right: 0;
}
.clip-segment-handle:hover {
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
width: 10px;
}
/* Zoom Slider */
input[type="range"] {
-webkit-appearance: none;
height: 6px;
background: #E0E0E0;
border-radius: 3px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: rgba(0, 123, 255, 0.9); /* Primary blue color */
cursor: pointer;
}
/* Tooltip styles */
[data-tooltip] {
position: relative;
cursor: pointer;
}
[data-tooltip]::before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 0.8rem;
white-space: nowrap;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]::after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
margin-bottom: 0px;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
/* Only show tooltips on devices with mouse hover capability */
@media (hover: hover) and (pointer: fine) {
[data-tooltip]:hover::before,
[data-tooltip]:hover::after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips (simple hover labels) on touch devices */
@media (pointer: coarse) {
[data-tooltip]::before,
[data-tooltip]::after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
/* Fix for buttons with disabled state */
button[disabled][data-tooltip]::before,
button[disabled][data-tooltip]::after {
opacity: 0.5;
}
/* Custom tooltip for action buttons - completely different approach */
.tooltip-action-btn {
position: relative;
}
.tooltip-action-btn[data-tooltip]::before,
.tooltip-action-btn[data-tooltip]::after {
/* Reset standard tooltip styles first */
opacity: 0;
visibility: hidden;
position: absolute;
pointer-events: none;
transition: all 0.3s ease;
}
.tooltip-action-btn[data-tooltip]::before {
content: attr(data-tooltip);
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: 12px;
padding: 4px 8px;
border-radius: 3px;
white-space: nowrap;
/* Position below the button */
bottom: -35px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
}
.tooltip-action-btn[data-tooltip]::after {
content: "";
border-width: 5px;
border-style: solid;
border-color: transparent transparent rgba(0, 0, 0, 0.8) transparent;
/* Position the arrow */
bottom: -15px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
}
/* Only show tooltips on devices with mouse hover capability */
@media (hover: hover) and (pointer: fine) {
.tooltip-action-btn:hover[data-tooltip]::before,
.tooltip-action-btn:hover[data-tooltip]::after {
opacity: 1;
visibility: visible;
}
}
/* Ensure tooltip container has proper space */
/* Segment tooltip styles */
.segment-tooltip {
background-color: rgba(179, 217, 255, 0.95); /* Light blue color */
color: #000000; /* Pure black text */
border-radius: 4px;
padding: 6px; /* Regular padding now that we have custom tooltips */
min-width: 140px; /* Increased width to accommodate the new delete button */
z-index: 1000; /* Increased z-index */
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}
.segment-tooltip::after {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid rgba(179, 217, 255, 0.95); /* Light blue color */
}
.tooltip-time {
font-size: 0.85rem;
font-weight: bold;
text-align: center;
margin-bottom: 6px;
color: #000000; /* Pure black text */
}
.tooltip-actions {
display: flex;
justify-content: space-between;
gap: 5px;
position: relative;
}
.tooltip-action-btn {
background-color: rgba(0, 123, 255, 0.2); /* Light blue background */
border: none;
border-radius: 3px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 6px;
transition: background-color 0.2s;
min-width: 20px !important;
}
.tooltip-action-btn:hover {
background-color: rgba(0, 123, 255, 0.4); /* Slightly darker on hover */
}
.tooltip-action-btn svg {
width: 100%;
height: 100%;
stroke: currentColor;
}
/* Adjust for the hand icons specifically */
.tooltip-action-btn.set-in svg,
.tooltip-action-btn.set-out svg {
width: 100%;
height: 100%;
margin: 0 auto;
fill: currentColor;
stroke: none;
}
/* Empty space tooltip styling */
.empty-space-tooltip {
background-color: white;
border-radius: 6px;
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.15);
padding: 8px;
z-index: 50;
min-width: 120px;
text-align: center;
position: relative;
}
.empty-space-tooltip::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
border-width: 8px 8px 0;
border-style: solid;
border-color: white transparent transparent;
}
.tooltip-action-btn.new-segment {
width: auto;
padding: 6px 10px;
display: flex;
align-items: center;
gap: 5px;
}
.tooltip-btn-text {
font-size: 0.8rem;
white-space: nowrap;
color: #000000; /* Pure black text */
}
.icon-new-segment {
width: 20px;
height: 20px;
}
/* Zoom dropdown styling */
.zoom-dropdown-container {
position: relative;
}
.zoom-button {
display: flex;
align-items: center;
gap: 6px;
background-color: rgba(108, 117, 125, 0.8);
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.zoom-button:hover {
background-color: rgba(108, 117, 125, 1);
}
.zoom-dropdown {
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
}
.zoom-option {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
.zoom-option:hover {
background-color: rgba(0, 123, 255, 0.1);
}
.zoom-option.selected {
background-color: rgba(0, 123, 255, 0.2);
font-weight: 500;
}
/* Save buttons styling */
.save-button, .save-copy-button {
background-color: rgba(0, 123, 255, 0.8);
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.save-button:hover, .save-copy-button:hover {
background-color: rgba(0, 123, 255, 1);
}
.save-copy-button {
background-color: rgba(108, 117, 125, 0.8);
}
.save-copy-button:hover {
background-color: rgba(108, 117, 125, 1);
}
/* Time navigation input styling */
.time-nav-label {
font-weight: 500;
font-size: 0.9rem;
}
.time-input {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid #ccc;
width: 150px;
font-family: monospace;
}
.time-button-group {
display: flex;
gap: 5px;
}
.time-button {
background-color: rgba(108, 117, 125, 0.8);
color: white;
border: none;
border-radius: 4px;
padding: 6px 8px;
font-size: 0.8rem;
cursor: pointer;
transition: background-color 0.2s;
}
.time-button:hover {
background-color: rgba(108, 117, 125, 1);
}
/* Timeline navigation and zoom controls responsiveness */
.timeline-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
padding: 12px;
background-color: #f5f5f5;
border-radius: 6px;
margin-top: 15px;
}
.time-navigation {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.controls-right {
display: flex;
align-items: center;
gap: 10px;
}
/* Media queries for responsive design */
@media (max-width: 768px) {
.timeline-controls {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.controls-right {
margin-top: 10px;
width: 100%;
justify-content: flex-start;
text-align: center;
align-items: center;
justify-content: center;
}
}
/* Timeline header styling */
.timeline-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.timeline-title {
font-weight: bold;
margin-right: 20px;
}
.timeline-title-text {
font-size: 1.1rem;
}
.current-time, .duration-time {
white-space: nowrap;
}
.time-code {
font-family: monospace;
font-weight: 500;
}
@media (max-width: 480px) {
.timeline-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.time-navigation {
width: 100%;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.time-button-group {
width: 100%;
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.controls-right {
flex-wrap: wrap;
gap: 8px;
}
.save-button, .save-copy-button {
margin-top: 8px;
width: 100%;
}
.zoom-dropdown-container {
width: 100%;
}
.zoom-button {
width: 100%;
justify-content: center;
}
}
@@ -0,0 +1,31 @@
/**
* A consistent logger utility that only outputs debug messages in development
* but always shows errors, warnings, and info messages.
*/
const logger = {
/**
* Logs debug messages only in development environment
*/
debug: (...args: any[]) => {
if (process.env.NODE_ENV === 'development') {
console.debug(...args);
}
},
/**
* Always logs error messages
*/
error: (...args: any[]) => console.error(...args),
/**
* Always logs warning messages
*/
warn: (...args: any[]) => console.warn(...args),
/**
* Always logs info messages
*/
info: (...args: any[]) => console.info(...args)
};
export default logger;
@@ -0,0 +1,57 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query";
async function throwIfResNotOk(res: Response) {
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
}
export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined,
): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include",
});
await throwIfResNotOk(res);
return res;
}
type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior;
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, {
credentials: "include",
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false,
},
mutations: {
retry: false,
},
},
});
@@ -0,0 +1,34 @@
/**
* Format seconds to HH:MM:SS.mmm format with millisecond precision
*/
export const formatDetailedTime = (seconds: number): string => {
if (isNaN(seconds)) return "00:00:00.000";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.round((seconds % 1) * 1000);
const formattedHours = String(hours).padStart(2, "0");
const formattedMinutes = String(minutes).padStart(2, "0");
const formattedSeconds = String(remainingSeconds).padStart(2, "0");
const formattedMilliseconds = String(milliseconds).padStart(3, "0");
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
};
/**
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
*/
export const formatTime = (seconds: number): string => {
// Use the detailed format instead of the old MM:SS format
return formatDetailedTime(seconds);
};
/**
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
*/
export const formatLongTime = (seconds: number): string => {
// Use the detailed format
return formatDetailedTime(seconds);
};
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,50 @@
/**
* Generate a solid color background for a segment
* Returns a CSS color based on the segment position
*/
export const generateSolidColor = (
time: number,
duration: number
): string => {
// Use the time position to create different colors
// This gives each segment a different color without needing an image
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
// Calculate color based on position
// Use an extremely light blue-based color palette
const hue = 210; // Blue base
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
/**
* Legacy function kept for compatibility
* Now returns a data URL for a solid color square instead of a video thumbnail
*/
export const generateThumbnail = async (
videoElement: HTMLVideoElement,
time: number
): Promise<string> => {
return new Promise((resolve) => {
// Create a small canvas for the solid color
const canvas = document.createElement('canvas');
canvas.width = 10; // Much smaller - we only need a color
canvas.height = 10;
const ctx = canvas.getContext('2d');
if (ctx) {
// Get the solid color based on time
const color = generateSolidColor(time, videoElement.duration);
// Fill with solid color
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// Convert to data URL (much smaller now)
const dataUrl = canvas.toDataURL('image/png', 0.5);
resolve(dataUrl);
});
};
@@ -0,0 +1,35 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
if (typeof window !== 'undefined') {
window.MEDIA_DATA = {
videoUrl: "",
mediaId: ""
};
}
declare global {
interface Window {
MEDIA_DATA: {
videoUrl: string;
mediaId: string;
};
seekToFunction?: (time: number) => void;
}
}
// Mount the components when the DOM is ready
const mountComponents = () => {
const trimContainer = document.getElementById("video-editor-trim-root");
if (trimContainer) {
const trimRoot = createRoot(trimContainer);
trimRoot.render(<App />);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountComponents);
} else {
mountComponents();
}
@@ -0,0 +1,118 @@
// API service for video trimming operations
interface TrimVideoRequest {
segments: {
startTime: string;
endTime: string;
name?: string;
}[];
saveAsCopy?: boolean;
saveIndividualSegments?: boolean;
}
interface TrimVideoResponse {
msg: string;
url_redirect: string;
status?: number; // HTTP status code for success/error
error?: string; // Error message if status is not 200
}
// Helper function to simulate delay
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// For now, we'll use a mock API that returns a promise
// This can be replaced with actual API calls later
export const trimVideo = async (
mediaId: string,
data: TrimVideoRequest
): Promise<TrimVideoResponse> => {
try {
// Attempt the real API call
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
// For error responses, return with error status and message
if (response.status === 400) {
// Handle 400 Bad Request - return with error details
try {
// Try to get error details from response
const errorData = await response.json();
return {
status: 400,
error: errorData.error || "An error occurred during processing",
msg: "Video Processing Error",
url_redirect: ""
};
} catch (parseError) {
// If can't parse response JSON, return generic error
return {
status: 400,
error: "An error occurred during video processing",
msg: "Video Processing Error",
url_redirect: ""
};
}
} else if (response.status !== 404) {
// Handle other error responses
try {
// Try to get error details from response
const errorData = await response.json();
return {
status: response.status,
error: errorData.error || "An error occurred during processing",
msg: "Video Processing Error",
url_redirect: ""
};
} catch (parseError) {
// If can't parse response JSON, return generic error
return {
status: response.status,
error: "An error occurred during video processing",
msg: "Video Processing Error",
url_redirect: ""
};
}
} else {
// If endpoint not ready (404), return mock success response
await delay(1500); // Simulate 1.5 second server delay
return {
status: 200, // Mock success status
msg: "Video Processed Successfully", // Updated per requirements
url_redirect: `./view?m=${mediaId}`
};
}
}
// Successful response
const jsonResponse = await response.json();
return {
status: 200,
msg: "Video Processed Successfully", // Ensure the success message is correct
url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
...jsonResponse
};
} catch (error) {
// For any fetch errors, return mock success response with delay
await delay(1500); // Simulate 1.5 second server delay
return {
status: 200, // Mock success status
msg: "Video Processed Successfully", // Consistent with requirements
url_redirect: `./view?m=${mediaId}`
};
}
/* Mock implementation that simulates network latency
return new Promise((resolve) => {
setTimeout(() => {
resolve({
msg: "Video is processing for trim",
url_redirect: `./view?m=${mediaId}`
});
}, 1500); // Simulate 1.5 second server delay
});
*/
};
@@ -0,0 +1,174 @@
#video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.clip-segments-container {
margin-top: 1rem;
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.clip-segments-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground, #333);
margin-bottom: 0.75rem;
}
.segment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
}
.segment-content {
display: flex;
align-items: center;
}
.segment-thumbnail {
width: 4rem;
height: 2.25rem;
background-size: cover;
background-position: center;
border-radius: 0.25rem;
margin-right: 0.75rem;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}
.segment-info {
display: flex;
flex-direction: column;
}
.segment-title {
font-weight: 500;
font-size: 0.875rem;
color: black;
}
.segment-time {
font-size: 0.75rem;
color: black;
}
.segment-duration {
font-size: 0.75rem;
margin-top: 0.25rem;
display: inline-block;
background-color: #f3f4f6;
padding: 0 0.5rem;
border-radius: 0.25rem;
color: black;
}
.segment-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.delete-button {
padding: 0.375rem;
color: #4b5563;
background-color: #e5e7eb;
border-radius: 9999px;
border: none;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
min-width: auto;
&:hover {
color: black;
background-color: #d1d5db;
}
svg {
height: 1rem;
width: 1rem;
}
}
.empty-message {
padding: 1rem;
text-align: center;
color: rgba(51, 51, 51, 0.7);
}
.segment-color-1 { background-color: rgba(59, 130, 246, 0.15); }
.segment-color-2 { background-color: rgba(16, 185, 129, 0.15); }
.segment-color-3 { background-color: rgba(245, 158, 11, 0.15); }
.segment-color-4 { background-color: rgba(239, 68, 68, 0.15); }
.segment-color-5 { background-color: rgba(139, 92, 246, 0.15); }
.segment-color-6 { background-color: rgba(236, 72, 153, 0.15); }
.segment-color-7 { background-color: rgba(6, 182, 212, 0.15); }
.segment-color-8 { background-color: rgba(250, 204, 21, 0.15); }
}
@@ -0,0 +1,317 @@
#video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.editing-tools-container {
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.flex-container {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
gap: 15px;
width: 100%;
}
.flex-container.single-row {
flex-wrap: nowrap;
}
/* Show full text on larger screens, hide short text */
.full-text {
display: inline;
}
.short-text {
display: none;
}
/* Reset text always visible by default */
.reset-text {
display: inline;
}
.button-group {
display: flex;
align-items: center;
&.play-buttons-group {
gap: 0.75rem;
justify-content: flex-start;
flex: 0 0 auto; /* Don't expand to fill space */
}
&.secondary {
gap: 0.75rem;
align-items: center;
justify-content: flex-end;
margin-left: auto; /* Push to right edge */
}
button {
display: flex;
align-items: center;
color: #333;
background: none;
border: none;
cursor: pointer;
min-width: auto;
/* Disabled hover effect as requested */
&:hover:not(:disabled) {
color: inherit;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.25rem;
}
}
}
.divider {
border-right: 1px solid #d1d5db;
height: 1.5rem;
margin: 0 0.5rem;
}
/* Style for play buttons with highlight effect */
.play-button, .preview-button {
font-weight: 600;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
min-width: 80px;
justify-content: center;
font-size: 0.875rem !important;
}
/* Completely disable ALL hover effects for play buttons */
.play-button:hover:not(:disabled), .preview-button:hover:not(:disabled) {
/* Reset everything to prevent any changes */
color: inherit !important;
transform: none !important;
font-size: 0.875rem !important;
width: auto !important;
background: none !important;
}
.play-button svg, .preview-button svg {
height: 1.5rem;
width: 1.5rem;
/* Make sure SVG scales with the button but doesn't change layout */
flex-shrink: 0;
}
/* Style for the preview mode message that replaces the play button */
.preview-mode-message {
display: flex;
align-items: center;
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
padding: 6px 12px;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
.preview-mode-message svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.5rem;
color: #3b82f6;
}
/* Add responsive button text class */
.button-text {
margin-left: 0.25rem;
}
/* Media queries for the editing tools */
@media (max-width: 992px) {
/* Hide text for undo/redo buttons on medium screens */
.button-group.secondary .button-text {
display: none;
}
}
@media (max-width: 768px) {
/* Keep all buttons in a single row, make them more compact */
.flex-container.single-row {
justify-content: space-between;
}
.button-group {
gap: 0.5rem;
}
/* Keep font size consistent regardless of screen size */
.preview-button, .play-button {
font-size: 0.875rem !important;
}
}
@media (max-width: 640px) {
/* At this breakpoint, make preview button text shorter */
.preview-button {
min-width: auto;
}
/* Switch to short text versions */
.full-text {
display: none;
}
.short-text {
display: inline;
}
/* Hide reset text */
.reset-text {
display: none;
}
/* Ensure buttons stay in correct position */
.button-group.play-buttons-group {
flex: initial;
justify-content: flex-start;
}
.button-group.secondary {
flex: initial;
justify-content: flex-end;
}
}
@media (max-width: 576px) {
/* Keep single row, left-align play buttons, right-align controls */
.flex-container.single-row {
justify-content: space-between;
flex-wrap: nowrap;
}
/* Fix left-align for play buttons */
.button-group.play-buttons-group {
justify-content: flex-start;
flex: 0 0 auto;
}
/* Fix right-align for editing controls */
.button-group.secondary {
justify-content: flex-end;
margin-left: auto;
}
/* Reduce button padding to fit more easily */
.button-group button {
padding: 0.25rem;
}
/* Smaller preview mode message */
.preview-mode-message {
font-size: 0.8rem;
padding: 4px 8px;
}
.divider {
margin: 0 0.25rem;
}
}
/* Very small screens - maintain layout but reduce further */
@media (max-width: 480px) {
.button-group.play-buttons-group,
.button-group.secondary {
gap: 0.25rem;
}
.divider {
display: none; /* Hide divider on very small screens */
}
}
}
@@ -0,0 +1,223 @@
#video-editor-trim-root {
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
animation: modal-fade-in 0.3s ease-out;
}
@keyframes modal-fade-in {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #333;
}
.modal-close-button {
background: none;
border: none;
cursor: pointer;
color: #666;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.modal-close-button:hover {
color: #000;
}
.modal-content {
padding: 20px;
color: #333;
font-size: 1rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid #eee;
gap: 12px;
}
.modal-button {
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.modal-button-primary {
background-color: #0066cc;
color: white;
}
.modal-button-primary:hover {
background-color: #0055aa;
}
.modal-button-secondary {
background-color: #f0f0f0;
color: #333;
}
.modal-button-secondary:hover {
background-color: #e0e0e0;
}
.modal-button-danger {
background-color: #dc3545;
color: white;
}
.modal-button-danger:hover {
background-color: #bd2130;
}
/* Modal content styles */
.modal-message {
margin-bottom: 16px;
font-size: 1rem;
}
.text-center {
text-align: center;
}
.modal-spinner {
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top: 4px solid #0066cc;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.modal-success-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #28a745;
font-size: 2rem;
}
.modal-error-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #dc3545;
font-size: 2rem;
}
.modal-choices {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.modal-choice-button {
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f8f8f8;
text-align: center;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
text-decoration: none;
color: #333;
}
.modal-choice-button:hover {
background-color: #eee;
border-color: #ccc;
}
.modal-choice-button svg {
margin-right: 8px;
}
.centered-choice {
margin: 0 auto;
width: auto;
min-width: 220px;
background-color: #0066cc;
color: white;
}
.centered-choice:hover {
background-color: #0055aa;
}
@media (max-width: 480px) {
.modal-container {
width: 95%;
}
.modal-actions {
flex-direction: column;
}
.modal-button {
width: 100%;
}
}
}
@@ -0,0 +1,583 @@
#video-editor-trim-root {
.timeline-container-card {
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.timeline-header {
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground, #333);
}
.timeline-title-text {
font-weight: 700;
}
.current-time {
font-size: 0.875rem;
color: var(--foreground, #333);
}
.time-code {
font-family: monospace;
background-color: #f3f4f6;
padding: 0 0.5rem;
border-radius: 0.25rem;
}
.duration-time {
font-size: 0.875rem;
color: var(--foreground, #333);
}
.timeline-scroll-container {
position: relative;
overflow: visible !important;
}
.timeline-container {
position: relative;
min-width: 100%;
background-color: #fafbfc;
height: 70px;
border-radius: 0.25rem;
overflow: visible !important;
}
.timeline-marker {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: red;
z-index: 30;
pointer-events: none;
}
.timeline-marker-head {
position: absolute;
top: -11px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 20px;
background-color: red;
border-radius: 50%;
pointer-events: auto;
cursor: pointer;
z-index: 31;
display: flex;
align-items: center;
justify-content: center;
}
.timeline-marker-head-icon {
color: white;
font-size: 14px;
font-weight: bold;
line-height: 1;
}
.trim-line-marker {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: rgba(0, 0, 0, 0.5);
z-index: 20;
}
.trim-handle {
position: absolute;
width: 10px;
height: 20px;
background-color: black;
cursor: ew-resize;
&.left {
right: 0;
top: 10px;
border-radius: 3px 0 0 3px;
}
&.right {
left: 0;
top: 10px;
border-radius: 0 3px 3px 0;
}
}
.timeline-thumbnail {
display: inline-block;
height: 70px;
border-right: 1px solid rgba(0, 0, 0, 0.03);
}
.split-point {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: rgba(255, 0, 0, 0.5);
z-index: 15;
}
.clip-segment {
position: absolute;
height: 70px;
border-radius: 4px;
z-index: 10;
border: 2px solid rgba(0, 0, 0, 0.15);
cursor: pointer;
&:hover {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3);
border-color: rgba(0, 0, 0, 0.4);
background-color: rgba(240, 240, 240, 0.8) !important;
}
&.selected {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.7);
border-color: rgba(59, 130, 246, 0.9);
}
&.selected:hover {
background-color: rgba(240, 248, 255, 0.85) !important;
}
}
.clip-segment-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.4rem;
background-color: rgba(0, 0, 0, 0.4);
color: white;
opacity: 1;
transition: background-color 0.2s;
line-height: 1.3;
}
.clip-segment:hover .clip-segment-info {
background-color: rgba(0, 0, 0, 0.5);
}
.clip-segment.selected .clip-segment-info {
background-color: rgba(59, 130, 246, 0.5);
}
.clip-segment.selected:hover .clip-segment-info {
background-color: rgba(59, 130, 246, 0.4);
}
.clip-segment-name {
font-weight: 700;
font-size: 12px;
}
.clip-segment-time {
font-size: 10px;
}
.clip-segment-duration {
font-size: 10px;
}
.clip-segment-handle {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
background-color: rgba(0, 0, 0, 0.2);
cursor: ew-resize;
}
.clip-segment-handle:hover {
background-color: rgba(0, 0, 0, 0.4);
}
.clip-segment-handle.left {
left: 0;
border-radius: 2px 0 0 2px;
}
.clip-segment-handle.right {
right: 0;
border-radius: 0 2px 2px 0;
}
/* Enhanced handles for touch devices */
@media (pointer: coarse) {
.clip-segment-handle {
width: 14px; /* Wider target for touch devices */
background-color: rgba(0, 0, 0, 0.4); /* Darker by default for better visibility */
}
.clip-segment-handle:after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 20px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 1px;
}
.clip-segment-handle.left:after {
box-shadow: -2px 0 0 rgba(0, 0, 0, 0.5);
}
.clip-segment-handle.right:after {
box-shadow: 2px 0 0 rgba(0, 0, 0, 0.5);
}
/* Active state for touch feedback */
.clip-segment-handle:active {
background-color: rgba(0, 0, 0, 0.6);
}
}
.segment-tooltip,
.empty-space-tooltip {
position: absolute;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
padding: 0.5rem;
z-index: 1000;
min-width: 150px;
text-align: center;
pointer-events: auto;
top: -90px !important;
}
.segment-tooltip:after,
.empty-space-tooltip:after {
content: '';
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid white;
}
.tooltip-time {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.5rem;
color: #333;
}
.tooltip-actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.tooltip-action-btn {
background-color: #f3f4f6;
border: none;
border-radius: 0.25rem;
padding: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #4b5563;
min-width: 20px !important;
}
.tooltip-action-btn:hover {
background-color: #e5e7eb;
color: #111827;
}
.tooltip-action-btn.delete {
color: #ef4444;
}
.tooltip-action-btn.delete:hover {
background-color: #fee2e2;
}
.tooltip-action-btn.new-segment {
padding: 0.375rem 0.5rem;
}
.tooltip-action-btn.new-segment .tooltip-btn-text {
margin-left: 0.25rem;
font-size: 0.75rem;
}
.tooltip-action-btn svg {
width: 1rem;
height: 1rem;
}
.timeline-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.75rem;
}
.time-navigation {
display: none;
align-items: center;
gap: 0.5rem;
}
.time-nav-label {
font-size: 0.875rem;
font-weight: 500;
}
.time-input {
border: 1px solid #d1d5db;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
width: 8rem;
font-size: 0.875rem;
}
.time-button-group {
display: flex;
}
.time-button {
background-color: #e5e7eb;
color: black;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
border: none;
cursor: pointer;
margin-right: 0.50rem;
}
.time-button:hover {
background-color: #d1d5db;
}
.time-button:first-child {
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.time-button:last-child {
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
.controls-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.zoom-dropdown-container {
position: relative;
z-index: 100;
display: none;
}
.zoom-button {
background-color: #374151;
color: white;
border: none;
border-radius: 0.25rem;
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
display: flex;
align-items: center;
cursor: pointer;
}
.zoom-button:hover {
background-color: #1f2937;
}
.zoom-button svg {
margin-left: 0.25rem;
}
.zoom-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
width: 9rem;
background-color: #374151;
color: white;
border-radius: 0.25rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 50;
max-height: 300px;
overflow-y: auto;
}
.zoom-option {
padding: 0.25rem 0.75rem;
cursor: pointer;
}
.zoom-option:hover {
background-color: #4b5563;
}
.zoom-option.selected {
background-color: #6b7280;
display: flex;
align-items: center;
}
.zoom-option svg {
margin-right: 0.25rem;
}
/* Save buttons container */
.save-buttons-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
flex-wrap: nowrap;
}
/* General styles for all save buttons */
.save-button,
.save-copy-button,
.save-segments-button {
color: #ffffff;
background: #0066cc;
border-radius: 0.25rem;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
border: none;
white-space: nowrap;
transition: background-color 0.2s;
min-width: fit-content;
}
/* Shared hover effect */
.save-button:hover,
.save-copy-button:hover,
.save-segments-button:hover {
background-color: rgba(9, 59, 109, 0.9);
}
/* Media query for smaller screens */
@media (max-width: 576px) {
.save-buttons-row {
width: 100%;
justify-content: space-between;
gap: 0.5rem;
}
.save-button,
.save-copy-button,
.save-segments-button {
flex: 1;
font-size: 0.7rem;
padding: 0.25rem 0.35rem;
}
}
/* Very small screens - adjust save buttons */
@media (max-width: 480px) {
.save-button,
.save-copy-button,
.save-segments-button {
font-size: 0.675rem;
padding: 0.25rem;
}
/* Remove margins for controls-right buttons */
.controls-right {
margin: 0;
}
.controls-right button {
margin: 0;
}
}
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
}
@@ -0,0 +1,259 @@
.two-row-tooltip {
display: flex;
flex-direction: column;
background-color: white;
padding: 6px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: relative;
z-index: 3000; /* Highest z-index to ensure it's above all other elements */
}
/* Hide ±100ms buttons for more compact tooltip */
.tooltip-time-btn[data-tooltip="Decrease by 100ms"],
.tooltip-time-btn[data-tooltip="Increase by 100ms"] {
display: none !important;
}
.tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 3px;
}
.tooltip-row:first-child {
margin-bottom: 6px;
}
.tooltip-time-btn {
background-color: #f0f0f0 !important;
border: none !important;
border-radius: 4px !important;
padding: 4px 8px !important;
font-size: 0.75rem !important;
font-weight: 500 !important;
color: #333 !important;
cursor: pointer !important;
transition: background-color 0.2s !important;
min-width: 20px !important;
}
.tooltip-time-btn:hover {
background-color: #e0e0e0 !important;
}
.tooltip-time-display {
font-family: monospace !important;
font-size: 0.875rem !important;
font-weight: 600 !important;
color: #333 !important;
padding: 4px 6px !important;
background-color: #f7f7f7 !important;
border-radius: 4px !important;
min-width: 100px !important;
text-align: center !important;
overflow: hidden !important;
}
.tooltip-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 3px;
position: relative;
z-index: 2500; /* Higher z-index to ensure buttons appear above other elements */
}
.tooltip-action-btn {
background-color: #f3f4f6;
border: none;
border-radius: 4px;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #4b5563;
width: 26px;
height: 26px;
min-width: 20px !important;
position: relative; /* Add relative positioning for tooltips */
}
/* Custom tooltip styles for second row action buttons - positioned below */
.tooltip-action-btn[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
height: 30px;
top: 35px; /* Position below the button with increased space */
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center horizontally */
margin-left: 0; /* Reset margin */
background-color: rgba(0, 0, 0, 0.85);
color: white;
text-align: left;
padding: 6px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 2500; /* High z-index */
pointer-events: none;
}
/* Triangle arrow pointing up to the button */
.tooltip-action-btn[data-tooltip]:after {
content: '';
position: absolute;
top: 35px; /* Match the before element */
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center horizontally */
border-width: 4px;
border-style: solid;
/* Arrow pointing down from button to tooltip */
border-color: rgba(0, 0, 0, 0.85) transparent transparent transparent;
margin-left: 0; /* Reset margin */
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 2500; /* High z-index */
pointer-events: none;
}
/* Show tooltips on hover - but only on devices with hover capability (desktops) */
@media (hover: hover) and (pointer: fine) {
.tooltip-action-btn[data-tooltip]:hover:before,
.tooltip-action-btn[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Keep the two-row-tooltip visible but hide button attribute tooltips on touch devices */
@media (pointer: coarse) {
.tooltip-action-btn[data-tooltip]:before,
.tooltip-action-btn[data-tooltip]:after {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
content: none !important;
}
}
.tooltip-action-btn:hover {
background-color: #e5e7eb;
color: #111827;
}
.tooltip-action-btn.delete {
color: #ef4444;
}
.tooltip-action-btn.delete:hover {
background-color: #fee2e2;
}
.tooltip-action-btn.play {
color: #10b981;
}
.tooltip-action-btn.play:hover {
background-color: #d1fae5;
}
.tooltip-action-btn.pause {
color: #3b82f6;
}
.tooltip-action-btn.pause:hover {
background-color: #dbeafe;
}
.tooltip-action-btn.play-from-start {
color: #4f46e5;
}
.tooltip-action-btn.play-from-start:hover {
background-color: #e0e7ff;
}
.tooltip-action-btn svg {
width: 16px;
height: 16px;
}
/* Adjust the new segment button style */
.tooltip-action-btn.new-segment {
width: auto;
height: auto;
padding: 6px 10px;
display: flex;
flex-direction: row;
color: #10b981;
}
.tooltip-action-btn.new-segment:hover {
background-color: #d1fae5;
}
.tooltip-action-btn.new-segment .tooltip-btn-text {
margin-left: 6px;
font-size: 0.75rem;
white-space: nowrap;
}
/* Additional mobile optimizations */
@media (max-width: 768px) {
.two-row-tooltip {
padding: 4px;
}
.tooltip-row:first-child {
margin-bottom: 4px;
}
.tooltip-time-btn {
min-width: 20px !important;
font-size: 0.7rem !important;
padding: 3px 6px !important;
}
.tooltip-time-display {
font-size: 0.8rem !important;
padding: 3px 4px !important;
min-width: 90px !important;
}
.tooltip-action-btn {
width: 24px;
height: 24px;
padding: 4px;
}
.tooltip-action-btn.new-segment {
padding: 4px 8px;
}
.tooltip-action-btn svg {
width: 14px;
height: 14px;
}
/* Adjust tooltip position for small screens - maintain the same position but adjust size */
.tooltip-action-btn[data-tooltip]:before {
min-width: 100px;
font-size: 11px;
padding: 4px 8px;
height: 24px;
top: 33px; /* Maintain the same relative distance on mobile */
}
.tooltip-action-btn[data-tooltip]:after {
top: 33px; /* Match the tooltip position */
}
}
@@ -0,0 +1,219 @@
#video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.video-player-container {
position: relative;
background-color: black;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1rem;
aspect-ratio: 16/9;
}
.video-player-container video {
width: 100%;
height: 100%;
cursor: pointer;
}
.play-pause-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
&.play-icon::before {
width: 0;
height: 0;
border-top: 15px solid transparent;
border-bottom: 15px solid transparent;
border-left: 25px solid white;
margin-left: 3px;
}
&.pause-icon::before {
width: 20px;
height: 25px;
border-left: 6px solid white;
border-right: 6px solid white;
}
}
.video-player-container:hover .play-pause-indicator {
opacity: 1;
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.75rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0;
transition: opacity 0.3s;
}
.video-player-container:hover .video-controls {
opacity: 1;
}
.video-current-time {
color: white;
font-size: 0.875rem;
}
.video-duration {
color: white;
font-size: 0.875rem;
}
.video-time-display {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
color: white;
font-size: 0.875rem;
}
.video-progress {
width: 100%;
height: 4px;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 2px;
position: relative;
cursor: pointer;
margin-bottom: 0.75rem;
&:hover {
height: 6px;
.video-scrubber {
transform: translate(-50%, -50%) scale(1.2);
}
}
}
.video-progress-fill {
height: 100%;
background-color: #ef4444;
border-radius: 2px;
position: absolute;
top: 0;
left: 0;
}
.video-scrubber {
width: 12px;
height: 12px;
background-color: #ef4444;
border-radius: 50%;
position: absolute;
top: 50%;
left: 0; /* This will be overridden by inline style */
/* Fix vertical centering by adjusting transform */
transform: translate(-50%, -50%);
transition: transform 0.2s;
/* Add a small border to make it more visible */
border: 1px solid rgba(255, 255, 255, 0.7);
}
.video-controls-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.mute-button,
.fullscreen-button {
min-width: auto;
color: white;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
svg {
width: 1.25rem;
height: 1.25rem;
}
}
}
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "client/src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
@@ -0,0 +1,14 @@
import { defineConfig } from "drizzle-kit";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL, ensure the database is provisioned");
}
export default defineConfig({
out: "./migrations",
schema: "./shared/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+52
View File
@@ -0,0 +1,52 @@
{
"name": "video-trim-js",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "tsx server/index.ts",
"dev:frontend": "vite",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"check": "tsc",
"db:push": "drizzle-kit push",
"build:django": "vite build --config vite.video-editor.config.ts --outDir ../../../static/video_editor"
},
"dependencies": {
"@neondatabase/serverless": "^0.10.4",
"@tanstack/react-query": "^5.74.4",
"clsx": "^2.1.1",
"drizzle-orm": "^0.39.1",
"drizzle-zod": "^0.7.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.3",
"zod": "^3.24.3"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@types/express": "4.17.21",
"@types/express-session": "^1.18.0",
"@types/node": "^20.17.30",
"@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.30.6",
"esbuild": "^0.25.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^5.4.18"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
}
}
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -0,0 +1,70 @@
import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => {
const start = Date.now();
const path = req.path;
let capturedJsonResponse: Record<string, any> | undefined = undefined;
const originalResJson = res.json;
res.json = function (bodyJson, ...args) {
capturedJsonResponse = bodyJson;
return originalResJson.apply(res, [bodyJson, ...args]);
};
res.on("finish", () => {
const duration = Date.now() - start;
if (path.startsWith("/api")) {
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
if (capturedJsonResponse) {
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
}
if (logLine.length > 80) {
logLine = logLine.slice(0, 79) + "…";
}
log(logLine);
}
});
next();
});
(async () => {
const server = await registerRoutes(app);
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error";
res.status(status).json({ message });
throw err;
});
// importantly only setup vite in development and after
// setting up all the other routes so the catch-all route
// doesn't interfere with the other routes
if (app.get("env") === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
// ALWAYS serve the app on port 5000
// this serves both the API and the client.
// It is the only port that is not firewalled.
const port = 5000;
server.listen({
port,
host: "0.0.0.0",
reusePort: true,
}, () => {
log(`serving on port ${port}`);
});
})();
@@ -0,0 +1,15 @@
import type { Express } from "express";
import { createServer, type Server } from "http";
import { storage } from "./storage";
export async function registerRoutes(app: Express): Promise<Server> {
// put application routes here
// prefix all routes with /api
// use storage to perform CRUD operations on the storage interface
// e.g. storage.insertUser(user) or storage.getUserByUsername(username)
const httpServer = createServer(app);
return httpServer;
}
@@ -0,0 +1,39 @@
import { users, type User, type InsertUser } from "@shared/schema";
// modify the interface with any CRUD methods
// you might need
export interface IStorage {
getUser(id: number): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>;
createUser(user: InsertUser): Promise<User>;
}
export class MemStorage implements IStorage {
private users: Map<number, User>;
currentId: number;
constructor() {
this.users = new Map();
this.currentId = 1;
}
async getUser(id: number): Promise<User | undefined> {
return this.users.get(id);
}
async getUserByUsername(username: string): Promise<User | undefined> {
return Array.from(this.users.values()).find(
(user) => user.username === username,
);
}
async createUser(insertUser: InsertUser): Promise<User> {
const id = this.currentId++;
const user: User = { ...insertUser, id };
this.users.set(id, user);
return user;
}
}
export const storage = new MemStorage();
@@ -0,0 +1,85 @@
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
import { createServer as createViteServer, createLogger } from "vite";
import { type Server } from "http";
import viteConfig from "../vite.config";
import { nanoid } from "nanoid";
const viteLogger = createLogger();
export function log(message: string, source = "express") {
const formattedTime = new Date().toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
console.log(`${formattedTime} [${source}] ${message}`);
}
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
customLogger: {
...viteLogger,
error: (msg, options) => {
viteLogger.error(msg, options);
process.exit(1);
},
},
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"..",
"client",
"index.html",
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`,
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}
export function serveStatic(app: Express) {
const distPath = path.resolve(import.meta.dirname, "public");
if (!fs.existsSync(distPath)) {
throw new Error(
`Could not find the build directory: ${distPath}, make sure to build the client first`,
);
}
app.use(express.static(distPath));
// fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => {
res.sendFile(path.resolve(distPath, "index.html"));
});
}
@@ -0,0 +1,17 @@
import { pgTable, text, serial, integer, boolean } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
username: text("username").notNull().unique(),
password: text("password").notNull(),
});
export const insertUserSchema = createInsertSchema(users).pick({
username: true,
password: true,
});
export type InsertUser = z.infer<typeof insertUserSchema>;
export type User = typeof users.$inferSelect;
@@ -0,0 +1,90 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./client/index.html", "./client/src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config;
+23
View File
@@ -0,0 +1,23 @@
{
"include": ["client/src/**/*", "shared/**/*", "server/**/*"],
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
"noEmit": true,
"module": "ESNext",
"strict": true,
"lib": ["esnext", "dom", "dom.iterable"],
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"moduleResolution": "bundler",
"baseUrl": ".",
"types": ["node", "vite/client"],
"paths": {
"@/*": ["./client/src/*"],
"@shared/*": ["./shared/*"]
}
}
}
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [
react(),
],
resolve: {
alias: {
"@": path.resolve(import.meta.dirname, "client", "src"),
"@shared": path.resolve(import.meta.dirname, "shared"),
},
},
root: path.resolve(import.meta.dirname, "client"),
build: {
outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true,
},
});
@@ -0,0 +1,53 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path, { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig({
plugins: [
react(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "client", "src"),
"@shared": path.resolve(__dirname, "shared"),
},
},
root: path.resolve(__dirname, "client"),
define: {
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'production')
}
},
build: {
minify: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'client/src/main.tsx'),
name: 'VideoEditor',
formats: ['iife'],
fileName: () => 'video-editor.js',
},
rollupOptions: {
output: {
// Ensure CSS file has a predictable name
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'video-editor.css';
return assetInfo.name;
},
// Add this to ensure the final bundle exposes React correctly
globals: {
'react': 'React',
'react-dom': 'ReactDOM'
}
},
},
// Output to Django's static directory
outDir: '../../../static/video_editor',
emptyOutDir: true,
external: ['react', 'react-dom']
},
});
File diff suppressed because it is too large Load Diff