summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor/audio-waveform.tsx115
-rw-r--r--apps/web/src/components/editor/media-panel.tsx26
-rw-r--r--apps/web/src/components/editor/timeline.tsx52
-rw-r--r--apps/web/src/components/header.tsx13
-rw-r--r--apps/web/src/components/landing/hero.tsx27
5 files changed, 192 insertions, 41 deletions
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx
new file mode 100644
index 00000000..75a5ae06
--- /dev/null
+++ b/apps/web/src/components/editor/audio-waveform.tsx
@@ -0,0 +1,115 @@
+import React, { useEffect, useRef, useState } from 'react';
+import WaveSurfer from 'wavesurfer.js';
+
+interface AudioWaveformProps {
+ audioUrl: string;
+ height?: number;
+ className?: string;
+}
+
+const AudioWaveform: React.FC<AudioWaveformProps> = ({
+ audioUrl,
+ height = 32,
+ className = ''
+}) => {
+ const waveformRef = useRef<HTMLDivElement>(null);
+ const wavesurfer = useRef<WaveSurfer | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const initWaveSurfer = async () => {
+ if (!waveformRef.current || !audioUrl) return;
+
+ try {
+ // Clean up any existing instance
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+
+ wavesurfer.current = WaveSurfer.create({
+ container: waveformRef.current,
+ waveColor: 'rgba(255, 255, 255, 0.6)',
+ progressColor: 'rgba(255, 255, 255, 0.9)',
+ cursorColor: 'transparent',
+ barWidth: 2,
+ barGap: 1,
+ height: height,
+ normalize: true,
+ interact: false,
+ });
+
+ // Event listeners
+ wavesurfer.current.on('ready', () => {
+ if (mounted) {
+ setIsLoading(false);
+ setError(false);
+ }
+ });
+
+ wavesurfer.current.on('error', (err) => {
+ console.error('WaveSurfer error:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ });
+
+ await wavesurfer.current.load(audioUrl);
+
+ } catch (err) {
+ console.error('Failed to initialize WaveSurfer:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ }
+ };
+
+ initWaveSurfer();
+
+ return () => {
+ mounted = false;
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+ };
+ }, [audioUrl, height]);
+
+ if (error) {
+ return (
+ <div className={`flex items-center justify-center ${className}`} style={{ height }}>
+ <span className="text-xs text-foreground/60">Audio unavailable</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className={`relative ${className}`}>
+ {isLoading && (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <span className="text-xs text-foreground/60">Loading...</span>
+ </div>
+ )}
+ <div
+ ref={waveformRef}
+ className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
+ style={{ height }}
+ />
+ </div>
+ );
+};
+
+export default AudioWaveform; \ No newline at end of file
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index f2b3f657..cdd67a86 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -17,27 +17,28 @@ export function MediaPanel() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
- // If no files, do nothing
- if (!files?.length) return;
-
+ if (!files || files.length === 0) return;
setIsProcessing(true);
+ setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
- const items = await processMediaFiles(files);
+ const processedItems = await processMediaFiles(files, (p) =>
+ setProgress(p)
+ );
// Add each processed media item to the store
- items.forEach((item) => {
- addMediaItem(item);
- });
+ processedItems.forEach((item) => addMediaItem(item));
} catch (error) {
- // Show error if processing fails
- console.error("File processing failed:", error);
+ // Show error toast if processing fails
+ console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
};
@@ -241,15 +242,12 @@ export function MediaPanel() {
{isProcessing ? (
<>
<Upload className="h-4 w-4 animate-spin" />
- <span className="hidden md:inline ml-2">Processing...</span>
+ <span className="hidden md:inline ml-2">{progress}%</span>
</>
) : (
<>
<Plus className="h-4 w-4" />
- <span
- className="hidden sm:inline ml-2"
- aria-label="Add file"
- >
+ <span className="hidden sm:inline ml-2" aria-label="Add file">
Add
</span>
</>
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index ce0ffa30..1ecbe545 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -35,6 +35,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
+import AudioWaveform from "./audio-waveform";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -68,6 +69,7 @@ export function Timeline() {
} = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
@@ -333,8 +335,12 @@ export function Timeline() {
} else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
setIsProcessing(true);
+ setProgress(0);
try {
- const processedItems = await processMediaFiles(e.dataTransfer.files);
+ const processedItems = await processMediaFiles(
+ e.dataTransfer.files,
+ (p) => setProgress(p)
+ );
for (const processedItem of processedItems) {
addMediaItem(processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
@@ -362,6 +368,7 @@ export function Timeline() {
toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
}
};
@@ -566,15 +573,6 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
onWheel={handleWheel}
>
- {/* Show overlay when dragging media over the timeline */}
- {isDragOver && (
- <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
- <div>
- Drop media here to add to timeline
- </div>
- </div>
- )}
-
{/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
@@ -602,10 +600,11 @@ export function Timeline() {
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
- <div className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: '18ch', textAlign: 'center' }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
@@ -953,6 +952,15 @@ export function Timeline() {
)}
</>
)}
+ {isDragOver && (
+ <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
+ <div>
+ {isProcessing
+ ? `Processing ${progress}%`
+ : "Drop media here to add to timeline"}
+ </div>
+ </div>
+ )}
</div>
</div>
</div>
@@ -1626,7 +1634,21 @@ function TimelineTrackContent({
);
}
- // Fallback for audio or videos without thumbnails
+ if (mediaItem.type === "audio") {
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="flex-1 min-w-0">
+ <AudioWaveform
+ audioUrl={mediaItem.url}
+ height={24}
+ className="w-full"
+ />
+ </div>
+ </div>
+ );
+ }
+
+ // Fallback for videos without thumbnails
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 27503cd0..74ba9359 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -29,8 +29,7 @@ export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
- <Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
- <span className="font-medium tracking-tight">OpenCut</span>
+ <Image src="/logo.svg" alt="OpenCut Logo" width={142} height={32} />
</Link>
);
@@ -51,7 +50,7 @@ export function Header() {
) : (
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button size="sm" className="text-sm ml-4">
- GitHub
+ GitHub {star}+
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
@@ -59,5 +58,11 @@ export function Header() {
</nav>
);
- return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
+ return (
+ <HeaderBase
+ className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
+ leftContent={leftContent}
+ rightContent={rightContent}
+ />
+ );
}
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 7b4588c8..fadc043a 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -8,6 +8,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
+import Image from "next/image";
interface HeroProps {
signupCount: number;
@@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) {
};
return (
- <div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4">
+ <div className="min-h-[calc(100vh-6rem)] flex flex-col justify-between items-center text-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -93,14 +94,24 @@ export function Hero({ signupCount }: HeroProps) {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
- className="inline-block"
+ className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
>
- <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
- The open source
- </h1>
- <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
- video editor
- </h1>
+ <h1>The First Open Source</h1>
+ <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
+ <span className="md:pl-24 ">Online</span>
+ <div className="relative -rotate-[3.76deg] max-w-[250px] md:max-w-[454px]">
+ <Image
+ className="mt-2.5 md:mt-0"
+ src="/frame.svg"
+ height={79}
+ width={459}
+ alt="frame"
+ />
+ <span className="absolute inset-0 flex items-center justify-center ">
+ Video Editor
+ </span>
+ </div>
+ </div>
</motion.div>
<motion.p