Merge branch 'main' into main

This commit is contained in:
Pulkit Garg
2025-06-24 10:49:15 +05:30
committed by GitHub
20 changed files with 834 additions and 91 deletions

3
.gitignore vendored
View File

@ -19,3 +19,6 @@
# typescript
/apps/web/next-env.d.ts
/apps/web/yarn.lock
# asdf version management
.tool-versions

View File

@ -26,29 +26,74 @@ A free, open-source video editor for web, desktop, and mobile.
## Getting Started
1. **Clone the repository:**
```bash
git clone <repo-url>
cd OpenCut
```
2. **Install dependencies:**
```bash
cd apps/web
npm install
# or, with Bun
bun install
```
3. **Run the development server:**
```bash
npm run dev
# or, with Bun
bun run dev
```
4. **Open in browser:**
Visit [http://localhost:3000](http://localhost:3000)
### Prerequisites
Before you begin, ensure you have the following installed on your system:
- [Bun](https://bun.sh/docs/installation)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
- [Node.js](https://nodejs.org/en/) (for `npm` alternative)
### Setup
1. **Clone the repository**
```bash
git clone <repo-url>
cd OpenCut
```
2. **Start backend services**
From the project root, start the PostgreSQL and Redis services:
```bash
docker-compose up -d
```
3. **Set up environment variables**
Navigate into the web app's directory and create a `.env` file from the example:
```bash
cd apps/web
cp .env.example .env
```
*The default values in the `.env` file should work for local development.*
4. **Install dependencies**
Install the project dependencies using `bun` (recommended) or `npm`.
```bash
# With bun
bun install
# Or with npm
npm install
```
5. **Run database migrations**
Apply the database schema to your local database:
```bash
# With bun
bun run db:push:local
# Or with npm
npm run db:push:local
```
6. **Start the development server**
```bash
# With bun
bun run dev
# Or with npm
npm run dev
```
The application will be available at [http://localhost:3000](http://localhost:3000).
=======
## Contributing
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
=======
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
Quick start for contributors:
@ -59,4 +104,4 @@ Quick start for contributors:
## License
MIT [Details](LICENSE)
[MIT LICENSE](LICENSE)

36
apps/web/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
FROM oven/bun:latest AS base
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["bun", "server.js"]

BIN
apps/web/bun.lockb Executable file

Binary file not shown.

View File

@ -0,0 +1,176 @@
"use client";
import { useRouter } from "next/navigation";
import { signIn } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Suspense, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ArrowLeft, Loader2 } from "lucide-react";
import { GoogleIcon } from "@/components/icons";
function LoginForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const handleLogin = async () => {
setError(null);
setIsEmailLoading(true);
const { error } = await signIn.email({
email,
password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
setIsEmailLoading(false);
return;
}
router.push("/editor");
};
const handleGoogleLogin = async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
});
router.push("/editor");
} catch (error) {
setError("Failed to sign in with Google. Please try again.");
setIsGoogleLoading(false);
}
};
const isAnyLoading = isEmailLoading || isGoogleLoading;
return (
<div className="flex flex-col space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleGoogleLogin}
variant="outline"
size="lg"
disabled={isAnyLoading}
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}{" "}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<Button
onClick={handleLogin}
disabled={isAnyLoading || !email || !password}
className="w-full h-11"
size="lg"
>
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Sign in"}
</Button>
</div>
</div>
);
}
export default function LoginPage() {
const router = useRouter();
return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="ghost"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">Welcome back</CardTitle>
<CardDescription className="text-base">
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<LoginForm />
</Suspense>
<div className="mt-6 text-center text-sm">
Don't have an account?{" "}
<Link
href="/signup"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Sign up
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,201 @@
"use client";
import { useRouter } from "next/navigation";
import { signUp, signIn } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Suspense, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Loader2, ArrowLeft } from "lucide-react";
import { GoogleIcon } from "@/components/icons";
function SignUpForm() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const handleSignUp = async () => {
setError(null);
setIsEmailLoading(true);
const { error } = await signUp.email({
name,
email,
password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
setIsEmailLoading(false);
return;
}
router.push("/login");
};
const handleGoogleSignUp = async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
});
router.push("/editor");
} catch (error) {
setError("Failed to sign up with Google. Please try again.");
setIsGoogleLoading(false);
}
};
const isAnyLoading = isEmailLoading || isGoogleLoading;
return (
<div className="flex flex-col space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleGoogleSignUp}
variant="outline"
size="lg"
disabled={isAnyLoading}
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}{" "}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Create a strong password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<Button
onClick={handleSignUp}
disabled={isAnyLoading || !name || !email || !password}
className="w-full h-11"
size="lg"
>
{isEmailLoading ? (
<Loader2 className="animate-spin" />
) : (
"Create account"
)}
</Button>
</div>
</div>
);
}
export default function SignUpPage() {
const router = useRouter();
return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="ghost"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">
Create your account
</CardTitle>
<CardDescription className="text-base">
Get started with your free account today
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<SignUpForm />
</Suspense>
<div className="mt-6 text-center text-sm">
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Sign in
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,5 +0,0 @@
import { AuthForm } from "@/components/auth-form";
export default function LoginPage() {
return <AuthForm mode="login" />;
}

View File

@ -1,5 +0,0 @@
import { AuthForm } from "@/components/auth-form";
export default function SignUpPage() {
return <AuthForm mode="signup" />;
}

View File

@ -0,0 +1,4 @@
/* Prevent scroll jumping on Mac devices when using the editor */
body {
overflow: hidden;
}

View File

@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
import "./editor.css";
import {
ResizablePanelGroup,
ResizablePanel,

View File

@ -54,7 +54,7 @@ const authConfig = {
description: "Sign in to your account to continue",
buttonText: "Sign in",
linkText: "Don't have an account?",
linkHref: "/auth/signup",
linkHref: "/signup",
linkLabel: "Sign up",
successRedirect: "/editor",
},
@ -63,9 +63,9 @@ const authConfig = {
description: "Get started with your free account today",
buttonText: "Create account",
linkText: "Already have an account?",
linkHref: "/auth/login",
linkHref: "/login",
linkLabel: "Sign in",
successRedirect: "/auth/login",
successRedirect: "/login",
},
} as const;

View File

@ -38,7 +38,6 @@ export function PropertiesPanel() {
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
: null;
// Get the first image clip for preview (simplified)
const firstImageClip = tracks
.flatMap((track) => track.clips)
.find((clip) => {

View File

@ -40,7 +40,7 @@ export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
// You can drag media here to add it to your project.
// Clips can be trimmed, deleted, and moved.
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } =
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim, undo, redo } =
useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
@ -102,6 +102,33 @@ export function Timeline() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedClips, removeClipFromTrack, clearSelectedClips]);
// Keyboard event for undo (Cmd+Z)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [undo]);
// Keyboard event for redo (Cmd+Shift+Z or Cmd+Y)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
e.preventDefault();
redo();
} else if ((e.metaKey || e.ctrlKey) && e.key === "y") {
e.preventDefault();
redo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [redo]);
// Mouse down on timeline background to start marquee
const handleTimelineMouseDown = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && e.button === 0) {
@ -294,18 +321,32 @@ export function Timeline() {
}
};
// Deselect all clips when clicking empty timeline area
const handleSeekToPosition = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickedTime = clickX / (50 * zoomLevel);
const clampedTime = Math.max(0, Math.min(duration, clickedTime));
seek(clampedTime);
};
const handleTimelineAreaClick = (e: React.MouseEvent) => {
// Only clear selection if the click target is the timeline background (not a child/clip)
if (e.target === e.currentTarget) {
clearSelectedClips();
// Calculate the clicked time position and seek to it
handleSeekToPosition(e);
}
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
}
// Otherwise, allow normal scrolling
};
// --- Playhead Scrubbing Handlers ---
@ -350,6 +391,92 @@ export function Timeline() {
onDrop: handleDrop,
};
// Action handlers for toolbar
const handleSplitSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
return;
}
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find(t => t.id === trackId);
const clip = track?.clips.find(c => c.id === clipId);
if (clip && track) {
const splitTime = currentTime;
const effectiveStart = clip.startTime;
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
updateClipTrim(track.id, clip.id, clip.trimStart, clip.trimEnd + (effectiveEnd - splitTime));
addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (split)",
duration: clip.duration,
startTime: splitTime,
trimStart: clip.trimStart + (splitTime - effectiveStart),
trimEnd: clip.trimEnd,
});
}
}
});
toast.success("Split selected clip(s)");
};
const handleDuplicateSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
return;
}
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find(t => t.id === trackId);
const clip = track?.clips.find(c => c.id === clipId);
if (clip && track) {
addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (copy)",
duration: clip.duration,
startTime: clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd) + 0.1,
trimStart: clip.trimStart,
trimEnd: clip.trimEnd,
});
}
});
toast.success("Duplicated selected clip(s)");
};
const handleFreezeSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
return;
}
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find(t => t.id === trackId);
const clip = track?.clips.find(c => c.id === clipId);
if (clip && track) {
// Add a new freeze frame clip at the playhead
addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (freeze)",
duration: 1, // 1 second freeze frame
startTime: currentTime,
trimStart: 0,
trimEnd: clip.duration - 1,
});
}
});
toast.success("Freeze frame added for selected clip(s)");
};
const handleDeleteSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
return;
}
selectedClips.forEach(({ trackId, clipId }) => {
removeClipFromTrack(trackId, clipId);
});
clearSelectedClips();
toast.success("Deleted selected clip(s)");
};
return (
<div
className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
@ -422,7 +549,7 @@ export function Timeline() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
@ -458,7 +585,7 @@ export function Timeline() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" onClick={handleDuplicateSelected}>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
@ -467,7 +594,7 @@ export function Timeline() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
@ -476,7 +603,7 @@ export function Timeline() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
@ -526,10 +653,14 @@ export function Timeline() {
<div className="flex-1 relative overflow-hidden">
<ScrollArea className="w-full">
<div
className="relative h-12 bg-muted/30"
className="relative h-12 bg-muted/30 cursor-pointer"
style={{
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
}}
onClick={(e) => {
// Calculate the clicked time position and seek to it
handleSeekToPosition(e);
}}
>
{/* Time markers */}
{(() => {
@ -653,15 +784,14 @@ export function Timeline() {
</div>
{/* Timeline Tracks Content */}
<div className="flex-1 relative">
<ScrollArea className="h-full w-full">
<div className="flex-1 relative overflow-hidden">
<div className="w-full h-[600px] overflow-hidden flex" ref={timelineRef} style={{ position: 'relative' }}>
{/* Timeline grid and clips area (with left margin for sidebar) */}
<div
ref={timelineRef}
className="relative cursor-pointer select-none"
className="relative flex-1"
style={{
height: `${tracks.length * 60}px`,
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
minHeight:
tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
}}
onClick={handleTimelineAreaClick}
onMouseDown={handleTimelineMouseDown}
@ -704,7 +834,6 @@ export function Timeline() {
zoomLevel={zoomLevel}
setContextMenu={setContextMenu}
/>
</div>
))}
@ -722,7 +851,7 @@ export function Timeline() {
</>
)}
</div>
</ScrollArea>
</div>
</div>
</div>
</div>
@ -788,7 +917,7 @@ export function Timeline() {
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
useTimelineStore.getState().updateClipTrim(
updateClipTrim(
track.id,
clip.id,
clip.trimStart,
@ -1452,9 +1581,18 @@ function TimelineTrackContent({
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
onClick={(e) => {
e.stopPropagation();
const isSelected = selectedClips.some(
(c) => c.trackId === track.id && c.clipId === clip.id
);
if (e.metaKey || e.ctrlKey || e.shiftKey) {
// Multi-selection mode: toggle the clip
selectClip(track.id, clip.id, true);
} else if (isSelected) {
// If clip is already selected, deselect it
deselectClip(track.id, clip.id);
} else {
// If clip is not selected, select it (replacing other selections)
selectClip(track.id, clip.id, false);
}
}}
@ -1527,8 +1665,7 @@ function TimelineTrackContent({
{/* Drop position indicator */}
{isDraggedOver && dropPosition !== null && (
<div
className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
}`}
className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
style={{
left: `${dropPosition * 50 * zoomLevel}px`,
transform: "translateX(-50%)",

View File

@ -6,9 +6,27 @@ import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import { useSession } from "@/lib/auth-client";
import { getStars } from "@/lib/fetchGhStars";
import { Star } from "lucide-react";
import { useEffect, useState } from "react";
export function Header() {
const { data: session } = useSession();
const [star, setStar] = useState<string>("");
useEffect(() => {
const fetchStars = async () => {
try {
const data = await getStars();
setStar(data);
} catch (err) {
console.error("Failed to fetch GitHub stars", err);
}
};
fetchStars();
}, []);
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
@ -19,11 +37,18 @@ export function Header() {
const rightContent = (
<nav className="flex items-center">
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button variant="ghost" className="text-sm">
GitHub
<Button
variant="ghost"
className="flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<span className="hidden sm:inline">GitHub</span>
<span className="text-foreground flex items-center">
{star}+
<Star className="w-4 h-4 ml-1" />
</span>
</Button>
</Link>
<Link href={session ? "/editor" : "/auth/login"}>
<Link href={session ? "/editor" : "/login"}>
<Button size="sm" className="text-sm ml-4">
Start editing
<ArrowRight className="h-4 w-4" />

View File

@ -5,18 +5,33 @@ import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
interface HeroProps {
signupCount: number;
}
export function Hero({ signupCount }: HeroProps) {
const [star, setStar] = useState<string>();
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
useEffect(() => {
const fetchStars = async () => {
try {
const data = await getStars();
setStar(data);
} catch (err) {
console.error("Failed to fetch GitHub stars", err);
}
};
fetchStars();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -67,7 +82,7 @@ export function Hero({ signupCount }: HeroProps) {
};
return (
<div className="relative min-h-screen flex flex-col items-center justify-center text-center px-4">
<div className="relative min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -152,7 +167,7 @@ export function Hero({ signupCount }: HeroProps) {
href="https://github.com/OpenCut-app/OpenCut"
className="text-foreground underline"
>
GitHub
GitHub {star}+
</Link>
</motion.div>
</div>

View File

@ -0,0 +1,29 @@
export async function getStars(): Promise<string> {
try {
const res = await fetch(
"https://api.github.com/repos/OpenCut-app/OpenCut",
{
next: { revalidate: 3600 },
}
);
if (!res.ok) {
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
}
const data = await res.json();
const count = data.stargazers_count;
if (typeof count !== "number") {
throw new Error("Invalid stargazers_count from GitHub API");
}
if (count >= 1_000_000)
return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (count >= 1_000)
return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
return count.toString();
} catch (error) {
console.error("Failed to fetch GitHub stars:", error);
return "1.5k";
}
}

View File

@ -7,7 +7,7 @@ export async function middleware(request: NextRequest) {
const session = getSessionCookie(request);
if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
const loginUrl = new URL("/auth/login", request.url);
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(loginUrl);
}

View File

@ -20,6 +20,8 @@ export interface TimelineTrack {
interface TimelineStore {
tracks: TimelineTrack[];
history: TimelineTrack[][];
redoStack: TimelineTrack[][];
// Multi-selection
selectedClips: { trackId: string; clipId: string }[];
@ -53,12 +55,39 @@ interface TimelineStore {
// Computed values
getTotalDuration: () => number;
// New actions
undo: () => void;
redo: () => void;
pushHistory: () => void;
}
export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [],
history: [],
redoStack: [],
selectedClips: [],
pushHistory: () => {
const { tracks, history, redoStack } = get();
// Deep copy tracks
set({
history: [...history, JSON.parse(JSON.stringify(tracks))],
redoStack: [] // Clear redo stack when new action is performed
});
},
undo: () => {
const { history, redoStack, tracks } = get();
if (history.length === 0) return;
const prev = history[history.length - 1];
set({
tracks: prev,
history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
});
},
selectClip: (trackId, clipId, multi = false) => {
set((state) => {
const exists = state.selectedClips.some(
@ -86,6 +115,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
setSelectedClips: (clips) => set({ selectedClips: clips }),
addTrack: (type) => {
get().pushHistory();
const newTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
@ -100,12 +130,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
},
removeTrack: (trackId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.filter((track) => track.id !== trackId),
}));
},
addClipToTrack: (trackId, clipData) => {
get().pushHistory();
const newClip: TimelineClip = {
...clipData,
id: crypto.randomUUID(),
@ -124,19 +156,21 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
},
removeClipFromTrack: (trackId, clipId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
}
: track
),
tracks: state.tracks
.map((track) =>
track.id === trackId
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
: track
)
// Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
}));
},
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
get().pushHistory();
set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
@ -144,25 +178,29 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
if (!clipToMove) return state;
return {
tracks: state.tracks.map((track) => {
if (track.id === fromTrackId) {
return {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
};
} else if (track.id === toTrackId) {
return {
...track,
clips: [...track.clips, clipToMove],
};
}
return track;
}),
tracks: state.tracks
.map((track) => {
if (track.id === fromTrackId) {
return {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
};
} else if (track.id === toTrackId) {
return {
...track,
clips: [...track.clips, clipToMove],
};
}
return track;
})
// Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
};
});
},
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
@ -178,6 +216,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
},
updateClipStartTime: (trackId, clipId, startTime) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
@ -193,6 +232,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
},
toggleTrackMute: (trackId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
@ -214,4 +254,11 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
return Math.max(...trackEndTimes, 0);
},
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
},
}));

View File

@ -18,7 +18,8 @@ services:
start_period: 10s
redis:
image: redis
image: redis:7-alpine
restart: unless-stopped
ports:
- "6379:6379"
healthcheck:
@ -36,12 +37,46 @@ services:
SRH_MODE: env
SRH_TOKEN: example_token
SRH_CONNECTION_STRING: "redis://redis:6379"
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:80 || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 10s
web:
build:
context: ./apps/web
dockerfile: ./apps/web/Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://opencut:opencutthegoat@db:5432/opencut
- BETTER_AUTH_URL=http://localhost:3000
- BETTER_AUTH_SECRET=your-production-secret-key-here
- UPSTASH_REDIS_REST_URL=http://serverless-redis-http:80
- UPSTASH_REDIS_REST_TOKEN=example_token
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
depends_on:
db:
condition: service_healthy
serverless-redis-http:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
volumes:
postgres_data:
networks:
default:
name: opencut-network

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "Opencut",
"name": "OpenCut",
"lockfileVersion": 3,
"requires": true,
"packages": {}