diff --git a/.gitignore b/.gitignore index 24d66f8..3deed24 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ # typescript /apps/web/next-env.d.ts /apps/web/yarn.lock + +# asdf version management +.tool-versions diff --git a/README.md b/README.md index 3dce048..407f530 100644 --- a/README.md +++ b/README.md @@ -26,29 +26,74 @@ A free, open-source video editor for web, desktop, and mobile. ## Getting Started -1. **Clone the repository:** - ```bash - git clone - 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 + 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) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..ca1f098 --- /dev/null +++ b/apps/web/Dockerfile @@ -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"] \ No newline at end of file diff --git a/apps/web/bun.lockb b/apps/web/bun.lockb new file mode 100755 index 0000000..30e5a2f Binary files /dev/null and b/apps/web/bun.lockb differ diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..5f37ced --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -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(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 ( +
+ {error && ( + + Error + {error} + + )} + + +
+
+ +
+
+ + Or continue with + +
+
+
+
+ + setEmail(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> +
+
+ + setPassword(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> +
+ +
+
+ ); +} + +export default function LoginPage() { + const router = useRouter(); + + return ( +
+ + + + Welcome back + + Sign in to your account to continue + + + + + +
+ } + > + + +
+ Don't have an account?{" "} + + Sign up + +
+ + + + ); +} diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..d1feece --- /dev/null +++ b/apps/web/src/app/(auth)/signup/page.tsx @@ -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(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 ( +
+ {error && ( + + Error + {error} + + )} + + + +
+
+ +
+
+ + Or continue with + +
+
+ +
+
+ + setName(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> +
+
+ + setEmail(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> +
+
+ + setPassword(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> +
+ +
+
+ ); +} + +export default function SignUpPage() { + const router = useRouter(); + + return ( +
+ + + + + + Create your account + + + Get started with your free account today + + + + + +
+ } + > + + +
+ Already have an account?{" "} + + Sign in + +
+ + + + ); +} diff --git a/apps/web/src/app/auth/login/page.tsx b/apps/web/src/app/auth/login/page.tsx deleted file mode 100644 index 18117f4..0000000 --- a/apps/web/src/app/auth/login/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AuthForm } from "@/components/auth-form"; - -export default function LoginPage() { - return ; -} diff --git a/apps/web/src/app/auth/signup/page.tsx b/apps/web/src/app/auth/signup/page.tsx deleted file mode 100644 index 109ae5c..0000000 --- a/apps/web/src/app/auth/signup/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AuthForm } from "@/components/auth-form"; - -export default function SignUpPage() { - return ; -} diff --git a/apps/web/src/app/editor/editor.css b/apps/web/src/app/editor/editor.css new file mode 100644 index 0000000..f482c6d --- /dev/null +++ b/apps/web/src/app/editor/editor.css @@ -0,0 +1,4 @@ +/* Prevent scroll jumping on Mac devices when using the editor */ +body { + overflow: hidden; +} \ No newline at end of file diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/page.tsx index 64186a3..2efb59c 100644 --- a/apps/web/src/app/editor/page.tsx +++ b/apps/web/src/app/editor/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import "./editor.css"; import { ResizablePanelGroup, ResizablePanel, diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx index 182137e..3dd5603 100644 --- a/apps/web/src/components/auth-form.tsx +++ b/apps/web/src/components/auth-form.tsx @@ -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; diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx index 2b211c2..c784dab 100644 --- a/apps/web/src/components/editor/properties-panel.tsx +++ b/apps/web/src/components/editor/properties-panel.tsx @@ -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) => { diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index e97b9ce..ce81f9e 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -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 (
- @@ -458,7 +585,7 @@ export function Timeline() { - @@ -467,7 +594,7 @@ export function Timeline() { - @@ -476,7 +603,7 @@ export function Timeline() { - @@ -526,10 +653,14 @@ export function Timeline() {
{ + // Calculate the clicked time position and seek to it + handleSeekToPosition(e); + }} > {/* Time markers */} {(() => { @@ -653,15 +784,14 @@ export function Timeline() {
{/* Timeline Tracks Content */} -
- +
+
+ {/* Timeline grid and clips area (with left margin for sidebar) */}
0 ? `${tracks.length * 60}px` : "200px", }} onClick={handleTimelineAreaClick} onMouseDown={handleTimelineMouseDown} @@ -704,7 +834,6 @@ export function Timeline() { zoomLevel={zoomLevel} setContextMenu={setContextMenu} /> -
))} @@ -722,7 +851,7 @@ export function Timeline() { )}
- +
@@ -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 && (
); -} \ No newline at end of file +} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 109d022..9f842b2 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -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(""); + + useEffect(() => { + const fetchStars = async () => { + try { + const data = await getStars(); + setStar(data); + } catch (err) { + console.error("Failed to fetch GitHub stars", err); + } + }; + + fetchStars(); + }, []); + const leftContent = ( OpenCut Logo @@ -19,11 +37,18 @@ export function Header() { const rightContent = (