Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 53 additions & 16 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,53 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
output: "export",
experimental: {
scrollRestoration: true,
},
// Required for ffmpeg.wasm to load WASM files correctly
// Without this, Next.js might try to process .wasm files and break them
webpack: (config) => {
config.resolve.fallback = { fs: false };
return config;
},
};

export default nextConfig;
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
output: "export",
experimental: {
scrollRestoration: true,
/**
* Tree-shake lucide-react so only the icons actually used in the
* source are included in the production bundle, instead of the
* full icon library (~2 MB uncompressed).
*/
optimizePackageImports: ["lucide-react"],
},
// Required for ffmpeg.wasm to load WASM files correctly
// Without this, Next.js might try to process .wasm files and break them
webpack: (config) => {
config.resolve.fallback = { fs: false };

/**
* Split large third-party dependencies into separate chunks so the
* browser can cache them independently and users only re-download
* what actually changed between versions.
*/
config.optimization = {
...config.optimization,
splitChunks: {
chunks: "all",
cacheGroups: {
// Keep ffmpeg.wasm in its own chunk — it is very large and
// changes infrequently, so long-term caching is valuable.
ffmpeg: {
test: /[\\/]node_modules[\\/]@ffmpeg[\\/]/,
name: "ffmpeg",
chunks: "all",
priority: 30,
enforce: true,
},
// Group all other heavy vendor code together.
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
chunks: "all",
priority: 10,
},
},
},
};

return config;
},
};

export default nextConfig;
41 changes: 37 additions & 4 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import VideoEditor from "@/components/VideoEditor";
import Footer from "@/components/Footer";
"use client";

import dynamic from "next/dynamic";
import { Suspense } from "react";

/**
* Lazy-load VideoEditor (the heaviest component ~32 KB).
* `ssr: false` is required because VideoEditor uses browser-only APIs
* (e.g. URL.createObjectURL, navigator, ffmpeg.wasm).
*/
const VideoEditor = dynamic(() => import("@/components/VideoEditor"), {
ssr: false,
loading: () => (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="flex flex-col items-center gap-3 text-[var(--muted)]">
<span className="h-8 w-8 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden="true" />
<p className="text-xs font-mono uppercase tracking-widest opacity-60">Loading editor…</p>
</div>
</div>
),
});

/**
* Footer lives below the fold — load it lazily so it never blocks
* the critical rendering path.
*/
const Footer = dynamic(() => import("@/components/Footer"), {
ssr: false,
loading: () => null,
});

export default function Home() {
return (
Expand All @@ -14,10 +42,15 @@ export default function Home() {
</a>

<main id="main-content" tabIndex={-1}>
<VideoEditor />
<Suspense fallback={null}>
<VideoEditor />
</Suspense>
</main>

<Footer />
<Suspense fallback={null}>
<Footer />
</Suspense>
</>
);
}

23 changes: 20 additions & 3 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"use client";

import { useState, useRef, useEffect, useMemo } from "react";
import dynamic from "next/dynamic";
import { useVideoEditor } from "@/hooks/useVideoEditor";
import { TextOverlay } from "@/lib/types";
import FileUpload from "./FileUpload";
import VideoPreview from "./VideoPreview";
import ThumbnailStrip from "./ThumbnailStrip";
import PresetSelector from "./PresetSelector";
import FramingControl from "./FramingControl";
import TrimControl from "./TrimControl";
Expand All @@ -16,15 +16,32 @@ import FormatSelector from "./FormatSelector";
import ExportSettings from "./ExportSettings";
import ExportOverlay from "./ExportOverlay";
import DownloadResult from "./DownloadResult";
import ImageOverlay from "./ImageOverlay"
import ImageOverlay from "./ImageOverlay";
import { getPresetById } from "@/lib/presets";

/**
* ThumbnailStrip is only rendered after a file is uploaded.
* Lazy-load it to keep the initial JS bundle smaller.
*/
const ThumbnailStrip = dynamic(() => import("./ThumbnailStrip"), {
ssr: false,
loading: () => <div className="h-12 w-full animate-pulse rounded bg-[var(--border)]" />,
});

/**
* OnboardingTour is shown only once on first visit — defer it entirely
* so it never blocks the initial render.
*/
const OnboardingTour = dynamic(() => import("./OnboardingTour"), {
ssr: false,
loading: () => null,
});

import { cn } from "@/lib/utils";
import {
Layers, Crop, Scissors, RotateCw, Volume2, Type,
SlidersHorizontal, Zap, AlertTriangle, Github, Copy
} from "lucide-react";
import OnboardingTour from "./OnboardingTour";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";

interface SectionProps {
Expand Down