lots of stuff

This commit is contained in:
Maze Winther
2026-01-23 15:40:01 +01:00
parent b1701e1b0e
commit c6deceaa89
292 changed files with 28825 additions and 26457 deletions
+99 -62
View File
@@ -18,9 +18,6 @@ When implementing new features:
```typescript
## apps/web/src/constants
captions-constants.ts
export const LANGUAGES: Language[]
editor-constants.ts
export const PLATFORM_LAYOUTS: Record<TPlatformLayout, string>
export const PANEL_CONFIG
@@ -44,6 +41,9 @@ font-constants.ts
export const getGoogleFonts = (): FontOption[] => ...
export const getSystemFonts = (): FontOption[] => ...
language-constants.ts
export const LANGUAGES
project-constants.ts
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[]
export const FPS_PRESETS
@@ -91,6 +91,7 @@ timeline-constants.tsx
export const TRACK_ICONS: Record<TrackType, React.ReactNode>
transcription-constants.ts
export const TRANSCRIPTION_LANGUAGES
export const TRANSCRIPTION_MODELS: TranscriptionModel[]
export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId
export const DEFAULT_CHUNK_LENGTH_SECONDS
@@ -152,6 +153,9 @@ use-keyboard-shortcuts-help.ts
}
export function useKeyboardShortcutsHelp()
use-mobile.ts
export function useIsMobile()
use-raf-loop.ts
export function useRafLoop(callback: ({ time }: { time: number }) => void)
@@ -536,7 +540,7 @@ definitions.ts
| "selection"
| "history"
| "timeline"
| "con...
| "controls"
export interface TActionDefinition {
description: string
category: TActionCategory
@@ -566,7 +570,7 @@ registry.ts
types.ts
export type TActionArgsMap = {
"seek-forward": { seconds: number } | undefined;
"seek-backward": { seconds: number } | und...
"seek-backward": { seconds: number } | undef...
export type TActionWithArgs = keyof TActionArgsMap
export type TActionWithOptionalArgs = | TActionWithNoArgs
| TKeysWithValueUndefined<TActionArgsMap>
@@ -576,7 +580,7 @@ types.ts
: undefined
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
: (_...
: (_?:...
export type TInvocationTrigger = "keypress" | "mouseclick"
export type TBoundActionList = {
[A in TAction]?: Array<TActionFunc<A>>;
@@ -635,12 +639,12 @@ parser.ts
export type GradientOrientation = LinearOrientation | Array<RadialOrientation>
export type Color = | { type: "hex"; value: string }
| { type: "literal"; value: string }
| { type: "rgb"; value:...
| { type: "rgb"; value: A...
export type ColorStop = Color & { length?: Distance }
export type GradientAst = {
type: GradientType;
orientation: GradientOrientation | undefined;
colorStops: Array<Color...
colorStops: Array<ColorSto...
export const parseGradient = ({
code,
}: {
@@ -653,7 +657,8 @@ parser.ts
audio.ts
export type CollectedAudioElement = Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl...
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
...
export function createAudioContext(): AudioContext
export interface DecodedAudio {
samples: Float32Array
@@ -1038,7 +1043,7 @@ scene-builder.ts
canvasSize: TCanvasSize;
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
duration: ...
duration: numb...
export function buildScene(params: BuildSceneParams)
scene-exporter.ts
@@ -1048,7 +1053,7 @@ scene-exporter.ts
progress: [progress: number];
complete: [buffer: ArrayBuffer];
error: [error: Error];
c...
cance...
export class SceneExporter extends EventEmitter<SceneExporterEvents> {
renderer: CanvasRenderer
format: ExportFormat
@@ -1123,10 +1128,11 @@ types.ts
"createdAt" | "updatedAt"
> & {
createdAt: string;
updatedAt: str...
updatedAt: string;
}
export type SerializedProject = Omit<TProject, "metadata" | "scenes"> & {
metadata: SerializedProjectMetadata;
scenes: Serial...
scenes: Serializ...
export interface StorageConfig {
projectsDb: string
mediaDb: string
@@ -1141,10 +1147,10 @@ index.ts
worker.ts
export type WorkerMessage = | { type: "init"; modelId: string }
| { type: "transcribe"; audio: Float32Array; language: stri...
| { type: "transcribe"; audio: Float32Array; language: strin...
export type WorkerResponse = | { type: "init-progress"; progress: number }
| { type: "init-complete" }
| { type: "init-err...
| { type: "init-error...
## apps/web/src/stores
@@ -1200,10 +1206,7 @@ timeline-store.ts
assets.ts
export type MediaType = "image" | "video" | "audio"
export interface MediaAsset extends Omit<
MediaAssetData,
"size" | "lastModified"
> {
export interface MediaAsset extends Omit<MediaAssetData, "size" | "lastModified"> {
file: File
url?: string
}
@@ -1215,13 +1218,13 @@ blog.ts
title: string;
content: string;
description: string;
cove...
coverImage...
export type Pagination = {
limit: number;
currpage: number;
nextPage: number | null;
prevPage: number | null;
to...
totalIt...
export type MarblePostList = {
posts: Post[];
pagination: Pagination;
@@ -1324,7 +1327,9 @@ keybinding.ts
| "j"
| "k"
| "l"
|...
| "m"
| "n"
...
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`
export type SingleCharacterShortcutKey = `${Key}`
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey
@@ -1332,6 +1337,10 @@ keybinding.ts
[key in ShortcutKey]?: TActionWithOptionalArgs;
}
language.ts
export type Language = (typeof LANGUAGES)[number]
export type LanguageCode = Language["code"]
project.ts
export type TBackground = | {
type: "color";
@@ -1339,7 +1348,8 @@ project.ts
}
| {
type: "blur";
blurIntensity...
blurIntensity: number;
}
export interface TCanvasSize {
width: number
height: number
@@ -1517,7 +1527,7 @@ timeline.ts
| CreateVideoElement
| CreateImageElement
| CreateTextElement
| Crea...
| CreateSt...
export interface ElementDragState {
isDragging: boolean
elementId: string | null
@@ -1556,6 +1566,7 @@ timeline.ts
}
transcription.ts
export type TranscriptionLanguage = LanguageCode | "auto"
export interface TranscriptionSegment {
text: string
start: number
@@ -1641,46 +1652,72 @@ ui.ts
## packages/ui/src/icons
base-icon.tsx
export function BaseIcon({
title,
size = 24,
viewBox = "0 0 24 24",
children,
...props
}: BaseIconProps)
create-icon.tsx
export function createIcon({ definition }: CreateIconParams)
index.tsx
export function GoogleIcon({ className }: { className?: string })
export function GithubIcon({ className }: { className?: string })
export function VercelIcon({ className }: { className?: string })
export function BackgroundIcon({ className }: { className?: string })
export function MarbleIcon({
className = "",
size = 32,
}: {
className?: string;
export const GoogleIcon
export const GithubIcon
export const VercelIcon
export const DataBuddyIcon
export const MarbleIcon
export const BackgroundIcon
export const SocialsIcon
export const TransitionUpIcon
export const MenuIcon
export const PencilIcon
export const LeftArrowIcon
export const TrashIcon
types.ts
export type IconProps = Omit<
SVGProps<SVGSVGElement>,
"children" | "width" | "height"
> & {
size?: number;
})
export function DataBuddyIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
})
export function SocialsIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
})
export function TransitionUpIcon({
className = "",
size = 16,
}: {
className?: string;
size?: number;
})
export function MenuIcon({
className = "",
size = 16,
}: {
className?: string;
size?: number;
})
title?: s...
export type IconNode = Array<{
element: keyof JSX.IntrinsicElements;
props: Record<string, string | number | undefined...
export type IconDefinition = {
title: string;
viewBox?: string;
nodes: IconNode;
}
## packages/ui/src/icons/collections
brand.ts
export const BRAND_COLLECTION
editor.ts
export const EDITOR_COLLECTION
index.ts
export const ICON_COLLECTIONS
ui.ts
export const UI_COLLECTION
## packages/ui/src/icons/registry
brand.ts
export const BRAND_ICONS
index.ts
export const ICON_DEFINITIONS
ui.ts
export const UI_ICONS
```
+1 -1
View File
@@ -6,7 +6,7 @@ alwaysApply: true
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Prettier's formatter.
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's formatter.
## Key Principles
-3
View File
@@ -1,3 +0,0 @@
.git
apps/web/.next/
apps/web/public/
-3
View File
@@ -1,3 +0,0 @@
{
"plugins": ["prettier-plugin-tailwindcss"]
}
+6 -2
View File
@@ -6,11 +6,15 @@
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"emmet.showExpandedAbbreviation": "never",
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
}
}
+4 -5
View File
@@ -25,7 +25,9 @@
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
@@ -41,7 +43,7 @@
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
"motion": "^12.18.1",
"nanoid": "^5.1.5",
@@ -51,7 +53,6 @@
"postgres": "^3.4.5",
"radix-ui": "^1.4.2",
"react": "^18.2.0",
"react-country-flag": "^3.1.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.54.0",
@@ -87,8 +88,6 @@
"drizzle-kit": "^0.31.4",
"dotenv": "^16.5.0",
"postcss": "^8",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
+1 -3
View File
@@ -1,5 +1,3 @@
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
export async function GET() {
return new Response("OK", { status: 200 });
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
+4 -4
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page";
import Prose from "@/components/ui/prose";
import { Separator } from "@/components/ui/separator";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
import { Post, Author } from "@/types/blog";
import { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import type { Author, Post } from "@/types/blog";
type PageProps = {
params: Promise<{ slug: string }>;
+4 -4
View File
@@ -1,10 +1,10 @@
import { Metadata } from "next";
import type { Metadata } from "next";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Link from "next/link";
import { getPosts } from "@/lib/blog/query";
import { Post, Author } from "@/types/blog";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
import type { Author, Post } from "@/types/blog";
export const metadata: Metadata = {
title: "Blog - OpenCut",
+8 -8
View File
@@ -1,12 +1,12 @@
import { Metadata } from "next";
import { Card, CardContent } from "@/components/ui/card";
import { GithubIcon } from "@opencut/ui/icons";
import { ExternalLink } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { GithubIcon } from "@opencut/ui/icons";
import { Card, CardContent } from "@/components/ui/card";
import { EXTERNAL_TOOLS, SOCIAL_LINKS } from "@/constants/site-constants";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
import { BasePage } from "../base-page";
export const metadata: Metadata = {
@@ -178,7 +178,7 @@ function AllContributorsSection({
</div>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{contributors.map((contributor, index) => (
{contributors.map((contributor) => (
<Link
key={contributor.id}
href={contributor.html_url}
@@ -251,7 +251,7 @@ function ExternalToolsSection() {
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
{EXTERNAL_TOOLS.map((tool, index) => (
<Link
key={index}
key={tool.url}
href={tool.url}
target="_blank"
className="block"
+13 -1
View File
@@ -14,7 +14,7 @@
--card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(0 0% 2%);
--primary: hsl(205, 84%, 47%);
--primary: hsl(210, 91%, 49%);
--primary-foreground: hsl(0 0% 91%);
--secondary: hsl(216, 13%, 92%);
--secondary-foreground: hsl(0 0% 2%);
@@ -46,6 +46,8 @@
--panel-accent: hsl(216, 8%, 86%);
--radius: 1rem;
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 4%);
@@ -84,6 +86,7 @@
--sidebar-ring: hsl(0 0% 83.1%);
--panel-background: hsl(0 0% 11%);
--panel-accent: hsl(0 0% 15%);
--sidebar: hsl(240 5.9% 10%);
}
@layer base {
@@ -247,3 +250,12 @@
background: var(--muted-foreground);
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+4 -4
View File
@@ -1,13 +1,13 @@
import { Metadata } from "next";
import type { Metadata } from "next";
import { BasePage } from "@/app/base-page";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
export const metadata: Metadata = {
title: "Privacy Policy - OpenCut",
+631
View File
@@ -0,0 +1,631 @@
"use client";
import {
ArrowDown01,
Calendar,
ChevronLeft,
MoreHorizontal,
Plus,
Search,
Trash2,
Video,
X,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { KeyboardEvent, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DeleteProjectDialog } from "@/components/editor/delete-project-dialog";
import { MigrationDialog } from "@/components/editor/migration-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
export default function ProjectsPage() {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set(),
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const router = useRouter();
const editor = useEditor();
useEffect(() => {
if (!editor.project.getIsInitialized()) {
editor.project.loadAllProjects();
}
}, [editor.project]);
const handleCreateProject = async () => {
try {
const projectId = await editor.project.createNewProject({
name: "New project",
});
router.push(`/editor/${projectId}`);
} catch (error) {
toast.error("Failed to create project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
const toggleSortOption = ({
sortField,
}: {
sortField: "createdAt" | "name";
}) => {
const isSameField = sortOption.startsWith(sortField);
const nextSortOption = isSameField
? `${sortField}-${sortOption.endsWith("asc") ? "desc" : "asc"}`
: `${sortField}-asc`;
setSortOption(nextSortOption);
};
const handleSelectProject = ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => {
const newSelected = new Set(selectedProjects);
if (checked) {
newSelected.add(projectId);
} else {
newSelected.delete(projectId);
}
setSelectedProjects(newSelected);
};
const handleSelectAll = ({ checked }: { checked: boolean }) => {
if (checked) {
setSelectedProjects(
new Set(projectsToDisplay.map((project) => project.id)),
);
} else {
setSelectedProjects(new Set());
}
};
const handleCancelSelection = () => {
setIsSelectionMode(false);
setSelectedProjects(new Set());
};
const handleBulkDelete = async () => {
try {
await Promise.all(
Array.from(selectedProjects).map((projectId) =>
editor.project.deleteProject({ id: projectId }),
),
);
} catch (error) {
toast.error("Failed to delete projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
}
};
const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
searchQuery,
sortOption,
});
const isAllSelected =
projectsToDisplay.length > 0 &&
selectedProjects.size === projectsToDisplay.length;
const hasSomeSelected =
selectedProjects.size > 0 &&
selectedProjects.size < projectsToDisplay.length;
const isLoading = editor.project.getIsLoading();
const isInitialized = editor.project.getIsInitialized();
return (
<div className="bg-background min-h-screen">
<MigrationDialog />
<div className="flex h-16 w-full items-center justify-between px-6 pt-2">
<Link
href="/"
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
>
<ChevronLeft className="size-5! shrink-0" />
<span className="text-sm font-medium">Back</span>
</Link>
<div className="block md:hidden">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelSelection}
>
<X className="size-4!" />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="size-4!" />
Delete ({selectedProjects.size})
</Button>
)}
</div>
) : (
<CreateButton onClick={handleCreateProject} />
)}
</div>
</div>
<main className="mx-auto max-w-6xl px-6 pt-6 pb-6">
<div className="mb-8 flex items-center justify-between">
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
Your projects
</h1>
<p className="text-muted-foreground">
{projectsToDisplay.length}{" "}
{projectsToDisplay.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && (
<span className="text-primary ml-2">
{selectedProjects.size} selected
</span>
)}
</p>
</div>
<div className="hidden md:block">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancelSelection}>
<X className="size-4!" />
Cancel
</Button>
<Button
variant="destructive"
disabled={selectedProjects.size === 0}
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="size-4!" />
Delete {selectedProjects.size} projects
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setIsSelectionMode(true)}
disabled={projectsToDisplay.length === 0}
>
Select projects
</Button>
<CreateButton onClick={handleCreateProject} />
</div>
)}
</div>
</div>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="max-w-72 flex-1">
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label="sort projects"
size="icon"
variant="outline"
className="size-9 items-center justify-center"
>
<ArrowDown01
strokeWidth={1.5}
className="!size-[1.05rem]"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
toggleSortOption({ sortField: "createdAt" })
}
>
Created{" "}
{sortOption.startsWith("createdAt") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toggleSortOption({ sortField: "name" })}
>
Name{" "}
{sortOption.startsWith("name") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by{" "}
{sortOption.startsWith("createdAt") ? "date" : "name"} (
{sortOption.endsWith("asc") ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{isLoading || !isInitialized ? (
<ProjectsLoader />
) : projectsToDisplay.length === 0 ? (
<EmptyState
search={{
query: searchQuery,
onClearSearch: () => setSearchQuery(""),
}}
onCreateProject={handleCreateProject}
/>
) : (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{projectsToDisplay.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
/>
))}
</div>
)}
</main>
<DeleteProjectDialog
isOpen={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onConfirm={handleBulkDelete}
/>
</div>
);
}
interface ProjectCardProps {
project: TProjectMetadata;
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => void;
}
function ProjectCard({
project,
isSelectionMode = false,
isSelected = false,
onSelect,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const editor = useEditor();
const handleDeleteProject = async () => {
await editor.project.deleteProject({ id: project.id });
setIsDropdownOpen(false);
};
const handleRenameProject = async ({ name }: { name: string }) => {
await editor.project.renameProject({ id: project.id, name });
setIsRenameDialogOpen(false);
};
const handleDuplicateProject = async () => {
setIsDropdownOpen(false);
await editor.project.duplicateProject({ id: project.id });
};
const handleCardClick = ({
event,
}: {
event: MouseEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const handleCardKeyDown = ({
event,
}: {
event: KeyboardEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const cardContent = (
<Card
className={`bg-background overflow-hidden border-none p-0 transition-all ${
isSelectionMode && isSelected ? "ring-primary ring-2" : ""
}`}
>
<div
className={`bg-muted relative aspect-square transition-opacity ${
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
}`}
>
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
<div className="bg-background/80 flex size-5 items-center justify-center rounded-full border backdrop-blur-xs">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
onSelect?.({
projectId: project.id,
checked: checked === true,
})
}
onClick={(event) => event.stopPropagation()}
className="size-4"
/>
</div>
</div>
)}
<div className="absolute inset-0">
{project.thumbnail ? (
<Image
src={project.thumbnail}
alt="Project thumbnail"
fill
className="object-cover"
/>
) : (
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
<Video className="text-muted-foreground size-12 shrink-0" />
</div>
)}
</div>
</div>
<CardContent className="flex flex-col gap-1 px-0 pt-5">
<div className="flex items-start justify-between">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium transition-colors">
{project.name}
</h3>
{!isSelectionMode && (
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
aria-label="project options"
variant="text"
size="sm"
className={`ml-2 size-6 shrink-0 p-0 transition-all ${
isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
onClick={(event) => event.preventDefault()}
>
<MoreHorizontal aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsRenameDialogOpen(true);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDuplicateProject();
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="space-y-1">
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<Calendar className="size-4!" />
<span>Created {formatDate({ date: project.createdAt })}</span>
</div>
</div>
</CardContent>
</Card>
);
return (
<>
{isSelectionMode ? (
<button
type="button"
onClick={(event) => handleCardClick({ event })}
onKeyDown={(event) => handleCardKeyDown({ event })}
className="group block w-full cursor-pointer text-left"
>
{cardContent}
</button>
) : (
<Link href={`/editor/${project.id}`} className="group block">
{cardContent}
</Link>
)}
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteProject}
/>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={(name) => handleRenameProject({ name })}
projectName={project.name}
/>
</>
);
}
function ProjectsLoader() {
const skeletonIds = Array.from(
{ length: 8 },
(_, index) => `skeleton-${index}`,
);
return (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{skeletonIds.map((skeletonId) => (
<div
key={skeletonId}
className="bg-background overflow-hidden border-none p-0"
>
<Skeleton className="bg-muted/50 aspect-square w-full" />
<div className="flex flex-col gap-1.5 px-0 pt-5">
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<div className="flex items-center gap-1.5">
<Skeleton className="bg-muted/50 h-4 w-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
</div>
</div>
</div>
))}
</div>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
<Plus className="size-4!" />
<span className="text-sm font-medium">New project</span>
</Button>
);
}
function EmptyState({
search,
onCreateProject,
}: {
search: { query: string; onClearSearch: () => void };
onCreateProject: () => void;
}) {
const editor = useEditor();
const savedProjects = editor.project.getSavedProjects();
if (savedProjects.length > 0) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<Search className="text-muted-foreground size-8" />
</div>
<h3 className="text-lg font-medium">No results found</h3>
<p className="text-muted-foreground max-w-md">
Your search for "{search.query}" did not return any results.
</p>
</div>
<Button onClick={search.onClearSearch} variant="outline">
Clear search
</Button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<Video className="text-muted-foreground size-8" />
</div>
<h3 className="text-lg font-medium">No projects yet</h3>
<p className="text-muted-foreground max-w-md">
Start creating your first video project. Import media, edit, and
export professional videos.
</p>
</div>
<Button size="lg" className="gap-2" onClick={onCreateProject}>
<Plus />
Create your first project
</Button>
</div>
);
}
+270 -583
View File
@@ -1,649 +1,336 @@
"use client";
import {
Calendar,
ChevronLeft,
ArrowDown,
Clock3,
Folder,
LayoutGrid,
List,
MoreHorizontal,
ArrowDown01,
Plus,
Search,
Trash2,
Video,
X,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { KeyboardEvent, MouseEvent } from "react";
import { useState, useEffect } from "react";
import { DeleteProjectDialog } from "@/components/editor/delete-project-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { MigrationDialog } from "@/components/editor/migration-dialog";
import type { TProjectMetadata } from "@/types/project";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { formatDate } from "@/utils/date";
import { useEffect, useMemo, useRef, useState } from "react";
const projects = [
{
id: "project-1",
name: "Summer Vlog 2025",
createdAtLabel: "Created Jan 20, 2026",
duration: "02:14",
},
{
id: "project-2",
name: "Product Launch Teaser",
createdAtLabel: "Created Jan 20, 2026",
duration: "00:45",
},
{
id: "project-3",
name: "Podcast Ep. 4",
createdAtLabel: "Created Jan 18, 2026",
duration: "03:30",
},
];
const thumbnailSrc = "/open-graph/default.jpg";
export default function ProjectsPage() {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set(),
const [selectedProjectIds, setSelectedProjectIds] = useState<Array<string>>(
[],
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const router = useRouter();
const editor = useEditor();
const selectAllRef = useRef<HTMLInputElement | null>(null);
const selectedProjectIdSet = useMemo(() => {
return new Set(selectedProjectIds);
}, [selectedProjectIds]);
const selectedCount = selectedProjectIds.length;
const totalCount = projects.length;
const isAllSelected = selectedCount > 0 && selectedCount === totalCount;
const isIndeterminate = selectedCount > 0 && selectedCount < totalCount;
useEffect(() => {
if (!editor.project.getIsInitialized()) {
editor.project.loadAllProjects();
if (!selectAllRef.current) {
return;
}
}, [editor.project]);
selectAllRef.current.indeterminate = isIndeterminate;
}, [isIndeterminate]);
const handleCreateProject = async () => {
try {
const projectId = await editor.project.createNewProject({
name: "New project",
});
router.push(`/editor/${projectId}`);
} catch (error) {
toast.error("Failed to create project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
const toggleSortOption = ({
sortField,
}: {
sortField: "createdAt" | "name";
}) => {
const isSameField = sortOption.startsWith(sortField);
const nextSortOption = isSameField
? `${sortField}-${sortOption.endsWith("asc") ? "desc" : "asc"}`
: `${sortField}-asc`;
setSortOption(nextSortOption);
};
const handleSelectProject = ({
const toggleProjectSelection = ({
projectId,
checked,
isSelected,
}: {
projectId: string;
checked: boolean;
isSelected: boolean;
}) => {
const newSelected = new Set(selectedProjects);
if (checked) {
newSelected.add(projectId);
} else {
newSelected.delete(projectId);
setSelectedProjectIds((previousIds) => {
if (isSelected) {
if (previousIds.includes(projectId)) {
return previousIds;
}
setSelectedProjects(newSelected);
};
const handleSelectAll = ({ checked }: { checked: boolean }) => {
if (checked) {
setSelectedProjects(
new Set(projectsToDisplay.map((project) => project.id)),
);
} else {
setSelectedProjects(new Set());
return [...previousIds, projectId];
}
};
const handleCancelSelection = () => {
setIsSelectionMode(false);
setSelectedProjects(new Set());
};
const handleBulkDelete = async () => {
try {
await Promise.all(
Array.from(selectedProjects).map((projectId) =>
editor.project.deleteProject({ id: projectId }),
),
);
} catch (error) {
toast.error("Failed to delete projects", {
description:
error instanceof Error ? error.message : "Please try again",
return previousIds.filter((id) => id !== projectId);
});
} finally {
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
}
};
const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
searchQuery,
sortOption,
});
const isAllSelected =
projectsToDisplay.length > 0 &&
selectedProjects.size === projectsToDisplay.length;
const hasSomeSelected =
selectedProjects.size > 0 &&
selectedProjects.size < projectsToDisplay.length;
const isLoading = editor.project.getIsLoading();
const isInitialized = editor.project.getIsInitialized();
const toggleSelectAll = ({ isSelected }: { isSelected: boolean }) => {
if (!isSelected) {
setSelectedProjectIds([]);
return;
}
setSelectedProjectIds(projects.map(({ id }) => id));
};
return (
<div className="bg-background min-h-screen">
<MigrationDialog />
<div className="flex h-16 w-full items-center justify-between px-6 pt-2">
<Link
href="/"
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
<div
className={`flex h-screen overflow-hidden bg-[#ffffff] text-slate-900 ${
selectedCount > 1 ? "multi-select" : ""
}`}
style={{ "--brand-blue": "#00A3FF" } as React.CSSProperties}
>
<ChevronLeft className="size-5! shrink-0" />
<span className="text-sm font-medium">Back</span>
</Link>
<div className="block md:hidden">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelSelection}
>
<X className="size-4!" />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="size-4!" />
Delete ({selectedProjects.size})
</Button>
)}
</div>
) : (
<CreateButton onClick={handleCreateProject} />
)}
</div>
</div>
<main className="mx-auto max-w-6xl px-6 pt-6 pb-6">
<div className="mb-8 flex items-center justify-between">
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
Your projects
</h1>
<p className="text-muted-foreground">
{projectsToDisplay.length}{" "}
{projectsToDisplay.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && (
<span className="text-primary ml-2">
{selectedProjects.size} selected
<aside className="flex w-64 flex-shrink-0 flex-col border-r border-slate-100 bg-white">
<div className="flex items-center gap-3 p-6 pl-8">
<Image
src="/logos/opencut/1k/logo-black.png"
alt="OpenCut Logo"
width={24}
height={24}
className="size-6"
/>
<span className="text-lg font-semibold tracking-tight text-slate-900">
OpenCut
</span>
)}
</p>
</div>
<div className="hidden md:block">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancelSelection}>
<X className="size-4!" />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="size-4!" />
Delete selected ({selectedProjects.size})
</Button>
)}
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setIsSelectionMode(true)}
disabled={projectsToDisplay.length === 0}
>
Select projects
</Button>
<CreateButton onClick={handleCreateProject} />
</div>
)}
</div>
</div>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="max-w-72 flex-1">
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label="sort projects"
size="icon"
variant="outline"
className="size-9 items-center justify-center"
<nav className="mt-2 flex-1 space-y-2 px-4">
<a
href="/projects"
className="flex items-center gap-3 rounded-2xl bg-blue-50/50 px-4 py-3 text-sm font-medium text-[#00A3FF]"
>
<ArrowDown01
strokeWidth={1.5}
className="!size-[1.05rem]"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
toggleSortOption({ sortField: "createdAt" })
}
>
Created{" "}
{sortOption.startsWith("createdAt") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toggleSortOption({ sortField: "name" })}
>
Name{" "}
{sortOption.startsWith("name") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by{" "}
{sortOption.startsWith("createdAt") ? "date" : "name"} (
{sortOption.endsWith("asc") ? "ascending" : "descending"})
<LayoutGrid className="h-5 w-5" />
Projects
</a>
<div className="px-4 pt-6 pb-2">
<p className="text-xs font-bold tracking-wider text-slate-400 uppercase">
Folders
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<a
href="/projects"
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium text-slate-500 hover:bg-slate-50 hover:text-slate-900"
>
<Folder className="h-5 w-5 text-slate-400" />
Marketing
</a>
<a
href="/projects"
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium text-slate-500 hover:bg-slate-50 hover:text-slate-900"
>
<Folder className="h-5 w-5 text-slate-400" />
Social media
</a>
</nav>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex h-20 flex-shrink-0 items-center justify-between px-8">
<div className="flex items-center gap-4">
<h1 className="text-xl font-bold text-slate-900">All projects</h1>
</div>
{isSelectionMode && projectsToDisplay.length > 0 && (
<div className="flex items-center gap-4">
<div className="group relative">
<Search className="absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search..."
className="w-64 rounded-full border border-slate-200 bg-white py-2.5 pr-4 pl-10 text-sm text-slate-900 placeholder:text-slate-400 focus:border-[#00A3FF] focus:ring-4 focus:ring-blue-500/10 focus:outline-none"
/>
</div>
<div className="flex rounded-full border border-slate-200 bg-white p-1">
<button
type="button"
onClick={() => handleSelectAll({ checked: !isAllSelected })}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleSelectAll({ checked: !isAllSelected });
}
}}
className="bg-muted/30 mb-6 flex w-full items-center gap-2 rounded-lg border p-4 hover:cursor-pointer"
tabIndex={0}
className="rounded-full bg-slate-100 p-2 text-slate-900"
aria-label="Grid view"
>
<Checkbox
checked={hasSomeSelected ? "indeterminate" : isAllSelected}
/>
<span className="text-sm font-medium">
{isAllSelected ? "Deselect all" : "Select all"}
</span>
<span className="text-muted-foreground text-sm">
({selectedProjects.size} of {projectsToDisplay.length} selected)
</span>
<LayoutGrid className="h-4 w-4" aria-hidden="true">
<title>Grid view</title>
</LayoutGrid>
</button>
)}
{isLoading || !isInitialized ? (
<ProjectsLoader />
) : projectsToDisplay.length === 0 ? (
<EmptyState
search={{
query: searchQuery,
onClearSearch: () => setSearchQuery(""),
}}
onCreateProject={handleCreateProject}
/>
) : (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{projectsToDisplay.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
/>
))}
</div>
)}
</main>
<DeleteProjectDialog
isOpen={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onConfirm={handleBulkDelete}
/>
</div>
);
}
interface ProjectCardProps {
project: TProjectMetadata;
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => void;
}
function ProjectCard({
project,
isSelectionMode = false,
isSelected = false,
onSelect,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const editor = useEditor();
const handleDeleteProject = async () => {
await editor.project.deleteProject({ id: project.id });
setIsDropdownOpen(false);
};
const handleRenameProject = async ({ name }: { name: string }) => {
await editor.project.renameProject({ id: project.id, name });
setIsRenameDialogOpen(false);
};
const handleDuplicateProject = async () => {
setIsDropdownOpen(false);
await editor.project.duplicateProject({ id: project.id });
};
const handleCardClick = ({
event,
}: {
event: MouseEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const handleCardKeyDown = ({
event,
}: {
event: KeyboardEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const cardContent = (
<Card
className={`bg-background overflow-hidden border-none p-0 transition-all ${isSelectionMode && isSelected ? "ring-primary ring-2" : ""
}`}
<button
type="button"
className="rounded-full p-2 text-slate-400 hover:text-slate-600"
aria-label="List view"
>
<List className="h-4 w-4" aria-hidden="true">
<title>List view</title>
</List>
</button>
</div>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-[#00A3FF] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[#008BE0] active:scale-95"
>
<Plus className="h-4 w-4" />
New project
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto px-8 pb-8">
<div
className={`bg-muted relative aspect-square transition-opacity ${isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
}`}
id="controls-row"
className="controls-row group mb-4 flex items-center gap-3 text-slate-700"
>
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
<div className="bg-background/80 flex size-5 items-center justify-center rounded-full border backdrop-blur-xs">
<Checkbox
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-3 rounded-lg p-1 text-sm font-medium select-none">
<input
ref={selectAllRef}
id="select-all"
type="checkbox"
className="custom-checkbox controls-select size-5"
checked={isAllSelected}
onChange={({ currentTarget }) => {
toggleSelectAll({ isSelected: currentTarget.checked });
}}
/>
<span className="text-slate-500 group-hover:text-slate-700">
Select all
</span>
</label>
</div>
<div className="mx-2 h-4 w-px bg-slate-200"></div>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg p-1 text-sm font-medium text-slate-500 hover:text-slate-900"
>
Name
<ArrowDown className="size-3.5" />
</button>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{projects.map(({ createdAtLabel, duration, id, name }) => {
const isSelected = selectedProjectIdSet.has(id);
return (
<div
key={id}
className="project-card group relative flex h-auto flex-col overflow-hidden rounded-[24px]"
data-project-id={id}
>
<div className="project-select-wrapper absolute top-4 left-4 z-20">
<input
type="checkbox"
className="custom-checkbox project-select h-6 w-6 cursor-pointer shadow-md"
checked={isSelected}
onCheckedChange={(checked) =>
onSelect?.({
projectId: project.id,
checked: checked === true,
})
}
onClick={(event) => event.stopPropagation()}
className="size-4"
onChange={({ currentTarget }) => {
toggleProjectSelection({
projectId: id,
isSelected: currentTarget.checked,
});
}}
/>
</div>
</div>
)}
<div className="absolute inset-0">
{project.thumbnail ? (
<div className="project-menu absolute top-4 right-4 z-20 opacity-0 group-hover:opacity-100">
<button
type="button"
className="rounded-full bg-white/90 p-2 text-slate-600 backdrop-blur-sm hover:bg-white hover:text-[#00A3FF]"
aria-label="Project menu"
>
<MoreHorizontal className="h-4 w-4" aria-hidden="true">
<title>Project menu</title>
</MoreHorizontal>
</button>
</div>
<div className="relative m-1 aspect-video overflow-hidden rounded-lg bg-slate-50">
<Image
src={project.thumbnail}
alt="Project thumbnail"
src={thumbnailSrc}
alt={`Thumbnail for ${name}`}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover"
/>
) : (
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
<Video className="text-muted-foreground size-12 shrink-0" />
</div>
)}
<div className="absolute right-2 bottom-2 rounded-lg bg-black/60 px-2 py-1 text-[11px] font-semibold text-white">
{duration}
</div>
</div>
<CardContent className="flex flex-col gap-1 px-0 pt-5">
<div className="flex items-start justify-between">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium transition-colors">
{project.name}
<div className="flex flex-1 flex-col justify-between px-2 pt-3 pb-5">
<div>
<h3 className="truncate text-[15px] font-semibold text-slate-900">
{name}
</h3>
{!isSelectionMode && (
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
aria-label="project options"
variant="text"
size="sm"
className={`ml-2 size-6 shrink-0 p-0 transition-all ${isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
onClick={(event) => event.preventDefault()}
>
<MoreHorizontal aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsRenameDialogOpen(true);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDuplicateProject();
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="space-y-1">
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<Calendar className="size-4!" />
<span>Created {formatDate({ date: project.createdAt })}</span>
</div>
</div>
</CardContent>
</Card>
);
return (
<>
{isSelectionMode ? (
<button
type="button"
onClick={(event) => handleCardClick({ event })}
onKeyDown={(event) => handleCardKeyDown({ event })}
className="group block w-full cursor-pointer text-left"
>
{cardContent}
</button>
) : (
<Link href={`/editor/${project.id}`} className="group block">
{cardContent}
</Link>
)}
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteProject}
/>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={(name) => handleRenameProject({ name })}
projectName={project.name}
/>
</>
);
}
function ProjectsLoader() {
return (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }, (_, index) => (
<div
key={`skeleton-${index}`}
className="bg-background overflow-hidden border-none p-0"
>
<Skeleton className="bg-muted/50 aspect-square w-full" />
<div className="flex flex-col gap-1.5 px-0 pt-5">
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<div className="flex items-center gap-1.5">
<Skeleton className="bg-muted/50 h-4 w-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
<div className="mt-2 flex items-center gap-2 text-slate-400">
<Clock3 className="size-4" aria-hidden="true" />
<p className="text-xs font-medium">{createdAtLabel}</p>
</div>
</div>
</div>
))}
</div>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
<Plus className="size-4!" />
<span className="text-sm font-medium">New project</span>
</Button>
);
}
function EmptyState({
search,
onCreateProject,
}: {
search: { query: string; onClearSearch: () => void };
onCreateProject: () => void;
}) {
const editor = useEditor();
const savedProjects = editor.project.getSavedProjects();
if (savedProjects.length > 0) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<Search className="text-muted-foreground size-8" />
})}
</div>
<h3 className="text-lg font-medium">No results found</h3>
<p className="text-muted-foreground max-w-md">
Your search for "{search.query}" did not return any results.
</p>
</div>
<Button onClick={search.onClearSearch} variant="outline">
Clear search
</Button>
</div>
</main>
<style jsx global>{`
.project-card:hover .card-actions {
opacity: 1;
}
.checkbox-wrapper:checked + div {
border-color: var(--brand-blue);
background-color: #eff6ff;
}
.controls-row.has-selection .controls-select {
opacity: 1;
}
.project-select-wrapper {
opacity: 0;
}
.project-card:hover .project-select-wrapper {
opacity: 1;
}
.project-card:has(.project-select:checked) .project-select-wrapper {
opacity: 1;
}
.multi-select .project-menu {
opacity: 0 !important;
pointer-events: none;
}
.custom-checkbox {
appearance: none;
background-color: #fff;
border: 2px solid #e2e8f0;
border-radius: 8px;
display: inline-grid;
place-content: center;
}
.custom-checkbox:checked {
background-color: var(--brand-blue);
border-color: var(--brand-blue);
}
.custom-checkbox:checked::after {
content: "";
width: 10px;
height: 10px;
background-color: #fff;
clip-path: polygon(
14% 44%,
0 58%,
40% 100%,
100% 24%,
86% 10%,
40% 70%
);
}
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<Video className="text-muted-foreground size-8" />
</div>
<h3 className="text-lg font-medium">No projects yet</h3>
<p className="text-muted-foreground max-w-md">
Start creating your first video project. Import media, edit, and
export professional videos.
</p>
</div>
<Button size="lg" className="gap-2" onClick={onCreateProject}>
<Plus />
Create your first project
</Button>
`}</style>
</div>
);
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { Metadata } from "next";
import type { Metadata } from "next";
import { BasePage } from "@/app/base-page";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
import { Badge } from "@/components/ui/badge";
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
import { cn } from "@/utils/ui";
import { BasePage } from "@/app/base-page";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
type StatusType = "complete" | "pending" | "default" | "info";
@@ -138,7 +138,7 @@ export default function RoadmapPage() {
<div className="mx-auto flex max-w-4xl flex-col gap-16">
<div className="flex flex-col gap-6">
{roadmapItems.map((item, index) => (
<RoadmapItem key={index} item={item} index={index} />
<RoadmapItem key={item.title} item={item} index={index} />
))}
</div>
<GitHubContributeSection
+4 -4
View File
@@ -1,10 +1,10 @@
import { Metadata } from "next";
import { ExternalLink } from "lucide-react";
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Sponsor, SPONSORS } from "@/constants/site-constants";
import { BasePage } from "@/app/base-page";
import { Card, CardContent } from "@/components/ui/card";
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
export const metadata: Metadata = {
title: "Sponsors - OpenCut",
+4 -4
View File
@@ -1,13 +1,13 @@
import { Metadata } from "next";
import type { Metadata } from "next";
import { BasePage } from "@/app/base-page";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
export const metadata: Metadata = {
title: "Terms of Service - OpenCut",
@@ -1,12 +1,10 @@
import { Upload, Plus, Image } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react";
interface MediaDragOverlayProps {
isVisible: boolean;
isProcessing?: boolean;
progress?: number;
onClick?: () => void;
isEmptyState?: boolean;
}
export function MediaDragOverlay({
@@ -14,24 +12,29 @@ export function MediaDragOverlay({
isProcessing = false,
progress = 0,
onClick,
isEmptyState = false,
}: MediaDragOverlayProps) {
if (!isVisible) return null;
const handleClick = (e: React.MouseEvent) => {
const handleClick = ({
event,
}: {
event: React.MouseEvent<HTMLButtonElement>;
}) => {
if (isProcessing || !onClick) return;
e.preventDefault();
e.stopPropagation();
event.preventDefault();
event.stopPropagation();
onClick();
};
return (
<div
className="bg-foreground/5 hover:bg-foreground/10 flex h-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center transition-all duration-200"
onClick={handleClick}
<button
className="bg-foreground/5 hover:bg-foreground/10 flex h-full w-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center transition-all duration-200"
type="button"
disabled={isProcessing || !onClick}
onClick={(event) => handleClick({ event })}
>
<div className="flex items-center justify-center">
<Upload className="text-foreground h-10 w-10" />
<Upload className="text-foreground size-10" />
</div>
<div className="space-y-2">
@@ -52,6 +55,6 @@ export function MediaDragOverlay({
</div>
</div>
)}
</div>
</button>
);
}
@@ -1,14 +1,17 @@
"use client";
import { Separator } from "@/components/ui/separator";
import {
type Tab,
useAssetsPanelStore,
} from "../../../stores/assets-panel-store";
import { TabBar } from "./tabbar";
import { Captions } from "./views/captions";
import { MediaView } from "./views/media";
import { useAssetsPanelStore, Tab } from "../../../stores/assets-panel-store";
import { TextView } from "./views/text";
import { SettingsView } from "./views/settings";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
import { Separator } from "@/components/ui/separator";
import { SettingsView } from "./views/settings";
import { Captions } from "./views/captions";
import { TextView } from "./views/text";
export function AssetsPanel() {
const { activeTab } = useAssetsPanelStore();
@@ -1,17 +1,17 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/utils/ui";
import {
TAB_KEYS,
tabs,
useAssetsPanelStore,
} from "../../../stores/assets-panel-store";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEffect, useRef, useState } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useAssetsPanelStore();
@@ -19,14 +19,14 @@ export function TabBar() {
const [showTopFade, setShowTopFade] = useState(false);
const [showBottomFade, setShowBottomFade] = useState(false);
const checkScrollPosition = () => {
const checkScrollPosition = useCallback(() => {
const element = scrollRef.current;
if (!element) return;
const { scrollTop, scrollHeight, clientHeight } = element;
setShowTopFade(scrollTop > 0);
setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
};
}, []);
useEffect(() => {
const element = scrollRef.current;
@@ -42,7 +42,7 @@ export function TabBar() {
element.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, []);
}, [checkScrollPosition]);
return (
<div className="relative flex">
@@ -53,7 +53,11 @@ export function TabBar() {
{TAB_KEYS.map((tabKey) => {
const tab = tabs[tabKey];
return (
<div
<Tooltip key={tabKey} delayDuration={10}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={tab.label}
className={cn(
"flex cursor-pointer flex-col items-center gap-0.5",
activeTab === tabKey
@@ -61,11 +65,15 @@ export function TabBar() {
: "text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
key={tabKey}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setActiveTab(tabKey);
}
}}
>
<Tooltip delayDuration={10}>
<TooltipTrigger asChild>
<tab.icon className="size-[1.1rem]! opacity-100 hover:opacity-75" />
</button>
</TooltipTrigger>
<TooltipContent
side="right"
@@ -78,7 +86,6 @@ export function TabBar() {
</div>
</TooltipContent>
</Tooltip>
</div>
);
})}
</div>
@@ -1,20 +1,30 @@
import { Button } from "@/components/ui/button";
import { PropertyGroup } from "../../properties-panel/property-item";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { LanguageSelect } from "@/components/language-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState, useRef } from "react";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { LANGUAGES } from "@/constants/captions-constants";
import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
import { Loader2 } from "lucide-react";
import type { TranscriptionProgress } from "@/types/transcription";
import type {
TranscriptionLanguage,
TranscriptionProgress,
} from "@/types/transcription";
import { transcriptionService } from "@/services/transcription";
import { decodeAudioToFloat32 } from "@/lib/media/audio";
import { buildCaptionChunks } from "@/lib/transcription/caption";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] = useState("auto");
const [selectedLanguage, setSelectedLanguage] =
useState<TranscriptionLanguage>("auto");
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -46,8 +56,7 @@ export function Captions() {
const result = await transcriptionService.transcribe({
audioData: samples,
language:
selectedLanguage === "auto" ? "auto" : selectedLanguage.toLowerCase(),
language: selectedLanguage === "auto" ? undefined : selectedLanguage,
onProgress: handleProgress,
});
@@ -85,18 +94,41 @@ export function Captions() {
}
};
const handleLanguageChange = ({ value }: { value: string }) => {
if (value === "auto") {
setSelectedLanguage("auto");
return;
}
const matchedLanguage = TRANSCRIPTION_LANGUAGES.find(
(language) => language.code === value,
);
if (!matchedLanguage) return;
setSelectedLanguage(matchedLanguage.code);
};
return (
<BaseView
ref={containerRef}
className="flex h-full flex-col justify-between"
>
<PropertyGroup title="Language">
<LanguageSelect
selectedCountry={selectedLanguage}
onSelect={setSelectedLanguage}
containerRef={containerRef}
languages={LANGUAGES}
/>
<Select
value={selectedLanguage}
onValueChange={(value) => handleLanguageChange({ value })}
>
<SelectTrigger className="bg-panel-accent h-8 w-full text-xs">
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto detect</SelectItem>
{TRANSCRIPTION_LANGUAGES.map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.name}
</SelectItem>
))}
</SelectContent>
</Select>
</PropertyGroup>
<div className="flex flex-col gap-4">
@@ -1,28 +1,22 @@
"use client";
import { useState, useMemo } from "react";
import type { LucideIcon } from "lucide-react";
import {
ArrowDown01,
CloudUpload,
Grid2X2,
Image,
ImageIcon,
List,
Loader2,
Music,
Video,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import Image from "next/image";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media/processing";
import { cn } from "@/utils/ui";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import type { MediaAsset } from "@/types/assets";
import type { CreateTimelineElement } from "@/types/timeline";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
import { DraggableItem } from "@/components/editor/draggable-item";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
@@ -35,14 +29,21 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DraggableItem } from "@/components/editor/draggable-item";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media/processing";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import type { MediaAsset } from "@/types/assets";
import type { CreateTimelineElement } from "@/types/timeline";
import { cn } from "@/utils/ui";
export function MediaView() {
const editor = useEditor();
@@ -176,12 +177,18 @@ export function MediaView() {
filteredMediaItems.forEach((item) => {
previews.set(item.id, <MediaPreview item={item} />);
previews.set(
`compact-${item.id}`,
<MediaPreview item={item} variant="compact" />,
);
});
return previews;
}, [filteredMediaItems]);
const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
const renderCompactPreview = (item: MediaAsset) =>
previewComponents.get(`compact-${item.id}`);
return (
<>
@@ -343,7 +350,6 @@ export function MediaView() {
isProcessing={isProcessing}
progress={progress}
onClick={openFilePicker}
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
/>
) : mediaViewMode === "grid" ? (
<GridView
@@ -357,7 +363,7 @@ export function MediaView() {
) : (
<ListView
items={filteredMediaItems}
renderPreview={renderPreview}
renderPreview={renderCompactPreview}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
@@ -551,15 +557,27 @@ function MediaTypePlaceholder({
);
}
function MediaPreview({ item }: { item: MediaAsset }) {
function MediaPreview({
item,
variant = "grid",
}: {
item: MediaAsset;
variant?: "grid" | "compact";
}) {
const shouldShowVideoOverlay = variant === "grid";
const shouldShowDurationBadge = variant === "grid";
if (item.type === "image") {
return (
<div className="flex size-full items-center justify-center">
<img
src={item.url}
<Image
src={item.url ?? ""}
alt={item.name}
className="max-h-full w-full object-cover"
loading="lazy"
width={item.width}
height={item.height}
unoptimized
/>
</div>
);
@@ -569,16 +587,21 @@ function MediaPreview({ item }: { item: MediaAsset }) {
if (item.thumbnailUrl) {
return (
<div className="relative size-full">
<img
<Image
src={item.thumbnailUrl}
alt={item.name}
className="size-full rounded object-cover"
loading="lazy"
unoptimized
/>
{shouldShowVideoOverlay ? (
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
<Video className="size-6 text-white drop-shadow-md" />
</div>
) : null}
{shouldShowDurationBadge ? (
<MediaDurationBadge duration={item.duration} />
) : null}
</div>
);
}
@@ -604,7 +627,9 @@ function MediaPreview({ item }: { item: MediaAsset }) {
);
}
return <MediaTypePlaceholder icon={Image} label="Unknown" variant="muted" />;
return (
<MediaTypePlaceholder icon={ImageIcon} label="Unknown" variant="muted" />
);
}
function SortMenuItem({
@@ -1,5 +1,9 @@
"use client";
import { PipetteIcon } from "lucide-react";
import Image from "next/image";
import { memo, useCallback, useMemo } from "react";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import {
Select,
SelectContent,
@@ -7,30 +11,26 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import {
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
PropertyGroup,
} from "../../properties-panel/property-item";
import {
FPS_PRESETS,
BLUR_INTENSITY_PRESETS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_COLOR,
FPS_PRESETS,
} from "@/constants/project-constants";
import { useEditorStore } from "@/stores/editor-store";
import { dimensionToAspectRatio } from "@/utils/geometry";
import Image from "next/image";
import { cn } from "@/utils/ui";
import { colors } from "@/data/colors/solid";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { PipetteIcon } from "lucide-react";
import { useMemo, memo, useCallback } from "react";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { useEditor } from "@/hooks/use-editor";
import { useEditorStore } from "@/stores/editor-store";
import type { TProject } from "@/types/project";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { cn } from "@/utils/ui";
import {
PropertyGroup,
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "../../properties-panel/property-item";
export function SettingsView() {
return <ProjectSettingsTabs />;
@@ -216,12 +216,14 @@ const BlurPreview = memo(
isSelected: boolean;
onSelect: () => void;
}) => (
<div
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
type="button"
aria-label={`Select ${blur.label} blur`}
>
<Image
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
@@ -236,7 +238,7 @@ const BlurPreview = memo(
{blur.label}
</span>
</div>
</div>
</button>
),
);
@@ -259,7 +261,7 @@ const BackgroundPreviews = memo(
return useMemo(
() =>
backgrounds.map((bg, index) => (
<div
<button
key={`${index}-${bg}`}
className={cn(
"border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
@@ -278,6 +280,8 @@ const BackgroundPreviews = memo(
}
}
onClick={() => handleColorSelect({ bg })}
type="button"
aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
/>
)),
[
@@ -1,27 +1,14 @@
"use client";
import { Input } from "@/components/ui/input";
import { useState, useMemo, useEffect } from "react";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
PlayIcon,
PauseIcon,
HeartIcon,
PlusIcon,
ListFilter,
PauseIcon,
PlayIcon,
PlusIcon,
} from "lucide-react";
import { useSoundsStore } from "@/stores/sounds-store";
import { useSoundSearch } from "@/hooks/use-sound-search";
import type { SoundEffect, SavedSound } from "@/types/sounds";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
@@ -31,8 +18,21 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/utils/ui";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { useSoundSearch } from "@/hooks/use-sound-search";
import { useSoundsStore } from "@/stores/sounds-store";
import type { SavedSound, SoundEffect } from "@/types/sounds";
import { cn } from "@/utils/ui";
export function SoundsView() {
return (
@@ -78,8 +78,6 @@ function SoundEffectsView() {
scrollPosition,
setScrollPosition,
loadSavedSounds,
isSoundSaved,
toggleSavedSound,
showCommercialOnly,
toggleCommercialFilter,
hasLoaded,
@@ -102,7 +100,6 @@ function SoundEffectsView() {
commercialOnly: showCommercialOnly,
});
// Audio playback state
const [playingId, setPlayingId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
null,
@@ -116,13 +113,18 @@ function SoundEffectsView() {
useEffect(() => {
loadSavedSounds();
}, [loadSavedSounds]);
if (!hasLoaded) {
let ignore = false;
useEffect(() => {
if (hasLoaded) {
return;
}
let shouldIgnore = false;
const fetchTopSounds = async () => {
try {
if (!ignore) {
if (!shouldIgnore) {
setLoading({ loading: true });
setError({ error: null });
}
@@ -131,7 +133,7 @@ function SoundEffectsView() {
"/api/sounds/search?page_size=50&sort=downloads",
);
if (!ignore) {
if (!shouldIgnore) {
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
@@ -145,37 +147,26 @@ function SoundEffectsView() {
setTotalCount({ count: data.count });
}
} catch (error) {
if (!ignore) {
if (!shouldIgnore) {
console.error("Failed to fetch top sounds:", error);
setError({
error:
error instanceof Error
? error.message
: "Failed to load sounds",
error instanceof Error ? error.message : "Failed to load sounds",
});
}
} finally {
if (!ignore) {
if (!shouldIgnore) {
setLoading({ loading: false });
}
}
};
const timeoutId = setTimeout(fetchTopSounds, 100);
const timeoutId = setTimeout(fetchTopSounds, 100, {});
return () => {
shouldIgnore = true;
clearTimeout(timeoutId);
ignore = true;
};
}
if (scrollAreaRef.current && scrollPosition > 0) {
const timeoutId = setTimeout(() => {
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
}, 100);
return () => clearTimeout(timeoutId);
}
}, [
hasLoaded,
setTopSoundEffects,
@@ -187,25 +178,37 @@ function SoundEffectsView() {
setTotalCount,
]);
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget;
setScrollPosition({ position: scrollTop });
handleScroll(event);
useEffect(() => {
if (!scrollAreaRef.current || scrollPosition <= 0) {
return;
}
const restoreScrollPosition = () => {
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
};
const displayedSounds = useMemo(() => {
const sounds = searchQuery ? searchResults : topSoundEffects;
return sounds;
}, [searchQuery, searchResults, topSoundEffects]);
const timeoutId = setTimeout(restoreScrollPosition, 100, {});
const playSound = (sound: SoundEffect) => {
return () => clearTimeout(timeoutId);
}, [scrollPosition, scrollAreaRef]);
const handleScrollWithPosition = ({
currentTarget,
}: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = currentTarget;
setScrollPosition({ position: scrollTop });
handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>);
};
const displayedSounds = searchQuery ? searchResults : topSoundEffects;
const playSound = ({ sound }: { sound: SoundEffect }) => {
if (playingId === sound.id) {
audioElement?.pause();
setPlayingId(null);
return;
}
// Stop previous sound
audioElement?.pause();
if (sound.previewUrl) {
@@ -213,10 +216,11 @@ function SoundEffectsView() {
audio.addEventListener("ended", () => {
setPlayingId(null);
});
audio.addEventListener("error", (e) => {
audio.addEventListener("error", () => {
setPlayingId(null);
});
audio.play().catch((error) => {
console.error("Failed to play sound preview:", error);
setPlayingId(null);
});
@@ -233,7 +237,9 @@ function SoundEffectsView() {
className="bg-panel-accent w-full"
containerClassName="w-full"
value={searchQuery}
onChange={(e) => setSearchQuery({ query: e.target.value })}
onChange={({ currentTarget }) =>
setSearchQuery({ query: currentTarget.value })
}
showClearIcon
onClear={() => setSearchQuery({ query: "" })}
/>
@@ -283,9 +289,7 @@ function SoundEffectsView() {
key={sound.id}
sound={sound}
isPlaying={playingId === sound.id}
onPlay={() => playSound(sound)}
isSaved={isSoundSaved({ soundId: sound.id })}
onToggleSaved={() => toggleSavedSound({ soundEffect: sound })}
onPlay={playSound}
/>
))}
{!isLoading && !isSearching && displayedSounds.length === 0 && (
@@ -311,33 +315,27 @@ function SavedSoundsView() {
isLoadingSavedSounds,
savedSoundsError,
loadSavedSounds,
isSoundSaved,
toggleSavedSound,
clearSavedSounds,
} = useSoundsStore();
// Audio playback state
const [playingId, setPlayingId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
null,
);
// Clear confirmation dialog state
const [showClearDialog, setShowClearDialog] = useState(false);
// Load saved sounds when tab becomes active
useEffect(() => {
loadSavedSounds();
}, [loadSavedSounds]);
const playSound = (sound: SavedSound) => {
const playSound = ({ sound }: { sound: SoundEffect }) => {
if (playingId === sound.id) {
audioElement?.pause();
setPlayingId(null);
return;
}
// Stop previous sound
audioElement?.pause();
if (sound.previewUrl) {
@@ -345,10 +343,11 @@ function SavedSoundsView() {
audio.addEventListener("ended", () => {
setPlayingId(null);
});
audio.addEventListener("error", (e) => {
audio.addEventListener("error", () => {
setPlayingId(null);
});
audio.play().catch((error) => {
console.error("Failed to play sound preview:", error);
setPlayingId(null);
});
@@ -357,8 +356,11 @@ function SavedSoundsView() {
}
};
// Convert SavedSound to SoundEffect for compatibility with AudioItem
const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({
const convertToSoundEffect = ({
savedSound,
}: {
savedSound: SavedSound;
}): SoundEffect => ({
id: savedSound.id,
name: savedSound.name,
description: "",
@@ -449,7 +451,10 @@ function SavedSoundsView() {
</Button>
<Button
variant="destructive"
onClick={async () => {
onClick={async ({
stopPropagation,
}: React.MouseEvent<HTMLButtonElement>) => {
stopPropagation();
await clearSavedSounds();
setShowClearDialog(false);
}}
@@ -467,13 +472,9 @@ function SavedSoundsView() {
{savedSounds.map((sound) => (
<AudioItem
key={sound.id}
sound={convertToSoundEffect(sound)}
sound={convertToSoundEffect({ savedSound: sound })}
isPlaying={playingId === sound.id}
onPlay={() => playSound(sound)}
isSaved={isSoundSaved({ soundId: sound.id })}
onToggleSaved={() =>
toggleSavedSound({ soundEffect: convertToSoundEffect(sound) })
}
onPlay={playSound}
/>
))}
</div>
@@ -490,37 +491,37 @@ function SongsView() {
interface AudioItemProps {
sound: SoundEffect;
isPlaying: boolean;
onPlay: () => void;
isSaved: boolean;
onToggleSaved: () => void;
onPlay: ({ sound }: { sound: SoundEffect }) => void;
}
function AudioItem({
sound,
isPlaying,
onPlay,
isSaved,
onToggleSaved,
}: AudioItemProps) {
const { addSoundToTimeline } = useSoundsStore();
function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
const { addSoundToTimeline, isSoundSaved, toggleSavedSound } =
useSoundsStore();
const isSaved = isSoundSaved({ soundId: sound.id });
const handleClick = () => {
onPlay();
onPlay({ sound });
};
const handleSaveClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggleSaved();
const handleSaveClick = ({
stopPropagation,
}: React.MouseEvent<HTMLButtonElement>) => {
stopPropagation();
toggleSavedSound({ soundEffect: sound });
};
const handleAddToTimeline = async (e: React.MouseEvent) => {
e.stopPropagation();
const handleAddToTimeline = async ({
stopPropagation,
}: React.MouseEvent<HTMLButtonElement>) => {
stopPropagation();
await addSoundToTimeline({ sound });
};
return (
<div
className="group flex cursor-pointer items-center gap-3 opacity-100 transition-opacity hover:opacity-75"
<div className="group flex items-center gap-3 opacity-100 transition-opacity hover:opacity-75">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={handleClick}
>
<div className="bg-accent relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md">
@@ -538,6 +539,7 @@ function AudioItem({
{sound.username}
</span>
</div>
</button>
<div className="flex items-center gap-3 pr-2">
<Button
@@ -1,43 +1,42 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import type { CSSProperties } from "react";
import { useStickersStore } from "@/stores/stickers-store";
import {
Loader2,
ArrowRight,
Clock,
Grid3X3,
Hash,
Loader2,
Smile,
Clock,
X,
Sparkles,
ArrowRight,
StickerIcon,
X,
} from "lucide-react";
import Image from "next/image";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { DraggableItem } from "@/components/editor/draggable-item";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { Button } from "@/components/ui/button";
import { InputWithBack } from "@/components/ui/input-with-back";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import {
getIconSvgUrl,
buildIconSvgUrl,
getIconSvgUrl,
ICONIFY_HOSTS,
POPULAR_COLLECTIONS,
} from "@/lib/iconify-api";
import { cn } from "@/utils/ui";
import Image from "next/image";
import { DraggableItem } from "@/components/editor/draggable-item";
import { InputWithBack } from "@/components/ui/input-with-back";
import { useStickersStore } from "@/stores/stickers-store";
import type { StickerCategory } from "@/types/stickers";
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { cn } from "@/utils/ui";
function isStickerCategory(value: string): value is StickerCategory {
return STICKER_CATEGORIES.includes(value as StickerCategory);
@@ -232,7 +231,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
if (Object.keys(collections).length === 0) {
loadCollections();
}
}, []);
}, [collections, loadCollections]);
useEffect(() => {
const timer = setTimeout(() => {
@@ -245,7 +244,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}, 500);
return () => clearTimeout(timer);
}, [localSearchQuery]);
}, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]);
const handleAddSticker = async (iconName: string) => {
try {
@@ -341,6 +340,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={clearRecentStickers}
className="hover:bg-accent ml-auto flex h-5 w-5 items-center justify-center rounded p-0"
>
@@ -511,6 +511,9 @@ function StickerItem({
const [hostIndex, setHostIndex] = useState(0);
useEffect(() => {
if (!iconName) {
return;
}
setImageError(false);
setHostIndex(0);
}, [iconName]);
@@ -1,5 +1,8 @@
"use client";
import { Plus } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import {
@@ -7,13 +10,10 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ReactNode, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/utils/ui";
import { useEditor } from "@/hooks/use-editor";
import { clearDragData, setDragData } from "@/lib/drag-data";
import type { TimelineDragData } from "@/types/drag";
import { cn } from "@/utils/ui";
export interface DraggableItemProps {
name: string;
@@ -1,9 +1,9 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { cn } from "@/utils/ui";
import { TTimeCode } from "@/types/time";
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time";
import type { TTimeCode } from "@/types/time";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
time: number;
@@ -96,7 +96,7 @@ export function EditableTimecode({
const handleDisplayKeyDown = ({
key,
preventDefault,
}: React.KeyboardEvent<HTMLSpanElement>) => {
}: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (key === "Enter" || key === " ") {
@@ -135,11 +135,11 @@ export function EditableTimecode({
}
return (
<span
<button
type="button"
onClick={startEditing}
onKeyDown={handleDisplayKeyDown}
role="button"
tabIndex={disabled ? -1 : 0}
disabled={disabled}
className={cn(
"text-primary cursor-pointer font-mono text-xs tabular-nums",
"hover:bg-muted/50 -mx-1 px-1 transition-colors hover:rounded",
@@ -149,6 +149,6 @@ export function EditableTimecode({
title={disabled ? undefined : "Click to edit time"}
>
{formattedTime}
</span>
</button>
);
}
@@ -27,6 +27,7 @@ import { ThemeToggle } from "../theme-toggle";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { PencilIcon } from "@opencut/ui/icons";
export function EditorHeader() {
return (
@@ -118,7 +119,7 @@ function ProjectDropdown() {
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-100 w-40">
<DropdownMenuContent align="start" className="z-100 w-52">
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={handleExit}
@@ -135,7 +136,7 @@ function ProjectDropdown() {
className="flex items-center gap-1.5"
onClick={() => setIsRenameDialogOpen(true)}
>
<SquarePen className="h-4 w-4" />
<PencilIcon className="h-4 w-4" />
Rename project
</DropdownMenuItem>
<DropdownMenuItem
@@ -14,9 +14,9 @@ import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
ExportFormat,
ExportQuality,
ExportResult,
type ExportFormat,
type ExportQuality,
type ExportResult,
} from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
import { useEditor } from "@/hooks/use-editor";
@@ -137,7 +137,6 @@ function ExportPopover({
return (
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
<>
{exportResult && !exportResult.success ? (
<ExportError
error={exportResult.error || "Unknown error occurred"}
@@ -271,7 +270,6 @@ function ExportPopover({
</div>
</>
)}
</>
</PopoverContent>
);
}
@@ -1,11 +1,11 @@
"use client";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { Button } from "../ui/button";
import { ArrowRightIcon } from "lucide-react";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
export function Onboarding() {
const [step, setStep] = useState(0);
@@ -97,10 +97,6 @@ function Title({ title }: { title: string }) {
return <h2 className="text-lg font-bold md:text-xl">{title}</h2>;
}
function Subtitle({ subtitle }: { subtitle: string }) {
return <h3 className="text-lg font-medium">{subtitle}</h3>;
}
function Description({ description }: { description: string }) {
return (
<div className="text-muted-foreground">
@@ -1,12 +1,12 @@
"use client";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useCallback, useMemo, useRef } from "react";
import { useRafLoop } from "@/hooks/use-raf-loop";
import { RootNode } from "@/services/renderer/nodes/root-node";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { buildScene } from "@/services/renderer/scene-builder";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/hooks/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
function usePreviewSize() {
const editor = useEditor();
@@ -1,5 +1,5 @@
import { AudioElement } from "@/types/timeline";
import type { AudioElement } from "@/types/timeline";
export function AudioProperties({ element }: { element: AudioElement }) {
export function AudioProperties({ _element }: { _element: AudioElement }) {
return <div className="space-y-4 p-5">Audio properties</div>;
}
@@ -1,6 +1,6 @@
import { cn } from "@/utils/ui";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { cn } from "@/utils/ui";
interface PropertyItemProps {
direction?: "row" | "column";
@@ -36,9 +36,9 @@ export function PropertyItemLabel({
className?: string;
}) {
return (
<label className={cn("text-muted-foreground text-xs", className)}>
<span className={cn("text-muted-foreground text-xs", className)}>
{children}
</label>
</span>
);
}
@@ -71,15 +71,16 @@ export function PropertyGroup({
return (
<PropertyItem direction="column" className={cn("gap-3", className)}>
<div
className="flex cursor-pointer items-center gap-1.5"
<button
type="button"
className="flex items-center gap-1.5"
onClick={() => setIsExpanded(!isExpanded)}
>
<PropertyItemLabel className={cn("cursor-pointer", titleClassName)}>
{title}
</PropertyItemLabel>
<ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} />
</div>
</button>
{isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
</PropertyItem>
);
@@ -1,7 +1,7 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import { FontFamily } from "@/constants/font-constants";
import { TextElement } from "@/types/timeline";
import type { FontFamily } from "@/constants/font-constants";
import type { TextElement } from "@/types/timeline";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -54,7 +54,7 @@ export function TextProperties({
if (value.trim() !== "") {
const parsed = parseInt(value, 10);
const fontSize = isNaN(parsed)
const fontSize = Number.isNaN(parsed)
? element.fontSize
: clamp({ value: parsed, min: 8, max: 300 });
editor.timeline.updateTextElement({
@@ -67,7 +67,7 @@ export function TextProperties({
const handleFontSizeBlur = () => {
const parsed = parseInt(fontSizeInput, 10);
const fontSize = isNaN(parsed)
const fontSize = Number.isNaN(parsed)
? element.fontSize
: clamp({ value: parsed, min: 8, max: 300 });
setFontSizeInput(fontSize.toString());
@@ -83,7 +83,7 @@ export function TextProperties({
if (value.trim() !== "") {
const parsed = parseInt(value, 10);
const opacityPercent = isNaN(parsed)
const opacityPercent = Number.isNaN(parsed)
? Math.round(element.opacity * 100)
: clamp({ value: parsed, min: 0, max: 100 });
editor.timeline.updateTextElement({
@@ -96,7 +96,7 @@ export function TextProperties({
const handleOpacityBlur = () => {
const parsed = parseInt(opacityInput, 10);
const opacityPercent = isNaN(parsed)
const opacityPercent = Number.isNaN(parsed)
? Math.round(element.opacity * 100)
: clamp({ value: parsed, min: 0, max: 100 });
setOpacityInput(opacityPercent.toString());
@@ -1,9 +1,9 @@
import { VideoElement, ImageElement } from "@/types/timeline";
import type { ImageElement, VideoElement } from "@/types/timeline";
export function VideoProperties({
element,
_element,
}: {
element: VideoElement | ImageElement;
_element: VideoElement | ImageElement;
}) {
return <div className="space-y-4 p-5">Video properties</div>;
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useMemo } from "react";
interface SelectionBoxProps {
startPos: { x: number; y: number } | null;
@@ -15,10 +15,10 @@ export function SelectionBox({
containerRef,
isActive,
}: SelectionBoxProps) {
const selectionBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
const selectionBoxStyle = useMemo(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) {
return null;
}
const containerRect = containerRef.current.getBoundingClientRect();
const startX = startPos.x - containerRect.left;
@@ -31,19 +31,19 @@ export function SelectionBox({
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
if (selectionBoxRef.current) {
selectionBoxRef.current.style.left = `${left}px`;
selectionBoxRef.current.style.top = `${top}px`;
selectionBoxRef.current.style.width = `${width}px`;
selectionBoxRef.current.style.height = `${height}px`;
}
}, [startPos, currentPos, isActive, containerRef]);
return {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
};
}, [containerRef, currentPos, isActive, startPos]);
if (!isActive || !startPos || !currentPos) return null;
if (!selectionBoxStyle) return null;
return (
<div
ref={selectionBoxRef}
style={selectionBoxStyle}
className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
/>
);
@@ -52,7 +52,7 @@ export function AudioWaveform({
useEffect(() => {
let mounted = true;
let ws = wavesurfer.current;
const ws = wavesurfer.current;
const initWaveSurfer = async () => {
if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
@@ -6,7 +6,7 @@ import {
EyeOff,
VolumeOff,
Volume2,
LucideIcon,
type LucideIcon,
EyeIcon,
Trash2,
} from "lucide-react";
@@ -23,7 +23,7 @@ import { TimelinePlayhead } from "./timeline-playhead";
import { SelectionBox } from "../selection-box";
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
import { SnapIndicator } from "./snap-indicator";
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
@@ -181,11 +181,12 @@ export function Timeline() {
});
return (
<div
<section
className={
"bg-panel relative flex h-full flex-col overflow-hidden rounded-sm transition-colors duration-200"
}
{...dragProps}
aria-label="Timeline"
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
>
@@ -323,11 +324,6 @@ export function Timeline() {
}
handleWheel(e);
}}
onMouseDown={(e) => {
handleTracksMouseDown(e);
handleSelectionMouseDown(e);
}}
onClick={handleTracksClick}
ref={tracksContainerRef}
>
<SelectionBox
@@ -380,8 +376,7 @@ export function Timeline() {
{tracks.length === 0 ? (
<div />
) : (
<>
{tracks.map((track, index) => (
tracks.map((track, index) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
@@ -432,14 +427,12 @@ export function Timeline() {
});
}}
>
<>
<EyeIcon />
<span>
{canTrackBeHidden(track) && track.hidden
? "Show track"
: "Hide track"}
</span>
</>
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
@@ -455,15 +448,14 @@ export function Timeline() {
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</>
))
)}
</div>
</ScrollArea>
</div>
</div>
</div>
</div>
</section>
);
}
@@ -1,7 +1,7 @@
"use client";
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { TimelineTrack } from "@/types/timeline";
interface SnapIndicatorProps {
@@ -37,10 +37,11 @@ import type {
TimelineTrack,
ElementDragState,
} from "@/types/timeline";
import { MediaAsset } from "@/types/assets";
import type { MediaAsset } from "@/types/assets";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { type TAction, invokeAction } from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import Image from "next/image";
interface TimelineElementProps {
element: TimelineElementType;
@@ -282,6 +283,10 @@ function ElementInner({
type: track.type,
},
)} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
>
<button
type="button"
className="absolute inset-0 size-full cursor-pointer"
onClick={(e) => onElementClick(e, element)}
onMouseDown={(e) => onElementMouseDown(e, element)}
onContextMenu={(e) => onElementMouseDown(e, element)}
@@ -304,6 +309,7 @@ function ElementInner({
)}
</div>
)}
</button>
{isSelected && (
<>
@@ -338,12 +344,14 @@ function ResizeHandle({
}) {
const isLeft = side === "left";
return (
<div
<button
type="button"
className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
>
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
</button>
);
}
@@ -369,10 +377,13 @@ function ElementContent({
if (element.type === "sticker") {
return (
<div className="flex size-full items-center gap-2 pl-2">
<img
<Image
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
alt={element.name}
className="size-5 shrink-0"
width={20}
height={20}
unoptimized
/>
<span className="truncate text-xs text-white">{element.name}</span>
</div>
@@ -444,7 +455,6 @@ function ElementContent({
top: isSelected ? "0.25rem" : "0rem",
bottom: isSelected ? "0.25rem" : "0rem",
}}
aria-label={`Tiled ${mediaAsset.type === "image" ? "background" : "thumbnail"} of ${mediaAsset.name}`}
/>
</div>
</div>
@@ -1,6 +1,6 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
@@ -84,9 +84,31 @@ export function TimelinePlayhead({
Math.min(rightBoundary, rawLeftPosition),
);
const handlePlayheadKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>,
) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const step = 1 / Math.max(1, editor.project.getActive().settings.fps);
const direction = event.key === "ArrowRight" ? 1 : -1;
const nextTime = Math.max(
0,
Math.min(duration, playheadPosition + direction * step),
);
editor.playback.seek({ time: nextTime });
};
return (
<div
ref={playheadRef}
role="slider"
aria-label="Timeline playhead"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={playheadPosition}
tabIndex={0}
className="pointer-events-auto absolute z-60"
style={{
left: `${leftPosition}px`,
@@ -95,6 +117,7 @@ export function TimelinePlayhead({
width: "2px",
}}
onMouseDown={handlePlayheadMouseDown}
onKeyDown={handlePlayheadKeyDown}
>
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
@@ -143,7 +143,7 @@ function ToolbarLeftSection() {
icon={<SplitSquareHorizontal />}
tooltip="Coming soon" /* separate audio */
disabled={true}
onClick={({ event }) => {}}
onClick={({ event: _event }) => {}}
/>
<ToolbarButton
@@ -158,7 +158,7 @@ function ToolbarLeftSection() {
icon={<Snowflake />}
tooltip="Coming soon" /* freeze frame */
disabled={true}
onClick={({ event }) => {}}
onClick={({ event: _event }) => {}}
/>
<ToolbarButton
@@ -220,7 +220,6 @@ function TimeDisplay() {
function SceneSelector() {
const editor = useEditor();
const currentScene = editor.scenes.getActiveScene();
const scenesCount = editor.scenes.getScenes().length;
return (
<div>
@@ -2,12 +2,12 @@
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TimelineElement } from "./timeline-element";
import { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { ElementDragState } from "@/types/timeline";
import type { ElementDragState } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor";
import { cn } from "@/utils/ui";
@@ -62,16 +62,16 @@ export function TimelineTrackContent({
);
return (
<div
<button
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
onClick={clearElementSelection}
type="button"
>
<div className="relative h-full min-w-full">
{track.elements.length === 0 ? (
<div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
) : (
<>
{track.elements.map((element) => {
track.elements.map((element) => {
const isSelected = isElementSelected({
trackId: track.id,
elementId: element.id,
@@ -95,10 +95,9 @@ export function TimelineTrackContent({
dragState={dragState}
/>
);
})}
</>
})
)}
</div>
</div>
</button>
);
}
-204
View File
@@ -1,204 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { ChevronDown, Globe } from "lucide-react";
import { cn } from "@/utils/ui";
import { motion } from "motion/react";
import ReactCountryFlag from "react-country-flag";
export interface Language {
code: string;
name: string;
flag?: string;
}
interface LanguageSelectProps {
selectedCountry: string;
onSelect: (country: string) => void;
containerRef: React.RefObject<HTMLDivElement>;
languages: Language[];
}
function FlagPreloader({ languages }: { languages: Language[] }) {
return (
<div className="pointer-events-none absolute -top-[9999px] left-0">
{languages.map((language) => (
<ReactCountryFlag
key={language.code}
countryCode={language.code}
svg
style={{ width: "1.05rem", height: "1.05rem" }}
/>
))}
</div>
);
}
export function LanguageSelect({
selectedCountry,
onSelect,
containerRef,
languages,
}: LanguageSelectProps) {
const [expanded, setExpanded] = useState(false);
const [isTapping, setIsTapping] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const collapsedHeight = "2.5rem";
const expandHeight = "12rem";
const buttonRef = useRef<HTMLButtonElement>(null);
const expand = () => {
setIsTapping(true);
setTimeout(() => setIsTapping(false), 600);
setExpanded(true);
buttonRef.current?.focus();
};
useEffect(() => {
if (!expanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsClosing(true);
setTimeout(() => setIsClosing(false), 600);
setExpanded(false);
buttonRef.current?.blur();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [expanded]);
const selectedLanguage = languages.find(
(lang) => lang.code === selectedCountry,
);
const handleSelect = ({
code,
e,
}: {
code: string;
e: React.MouseEvent<HTMLButtonElement>;
}) => {
e.stopPropagation();
e.preventDefault();
onSelect(code);
setExpanded(false);
};
return (
<div className="relative h-9 w-full">
<FlagPreloader languages={languages} />
<motion.button
type="button"
className={cn(
"absolute z-10 flex h-full w-full cursor-pointer flex-col items-start justify-between overflow-hidden rounded-lg px-3",
"!bg-foreground/10 text-foreground py-0 backdrop-blur-md",
"focus:border-ring focus:ring-ring/50 transition-[color,box-shadow] focus:ring-[1px]",
)}
initial={{
height: collapsedHeight,
scale: 1,
}}
animate={{
height: expanded ? expandHeight : collapsedHeight,
scale: isTapping ? [1, 0.985, 1] : 1,
}}
transition={{
height: { duration: 0.25, ease: [0.4, 0, 0.2, 1] },
scale: { duration: 0.6, ease: "easeOut" },
}}
onClick={expand}
ref={buttonRef}
>
{!expanded ? (
<div
className="flex w-full items-center justify-between"
style={{
height: collapsedHeight,
}}
>
<div className="flex items-center gap-2">
{selectedCountry === "auto" ? (
<Globe className="!size-[1.05rem]" />
) : (
<ReactCountryFlag
countryCode={selectedCountry}
svg
style={{ width: "1.05rem", height: "1.05rem" }}
/>
)}
<span className="pt-[0.05rem]">
{selectedCountry === "auto" ? "Auto" : selectedLanguage?.name}
</span>
</div>
</div>
) : (
<div className="scrollbar-hidden my-2.5 flex w-full flex-col gap-2 overflow-y-auto">
<LanguageButton
language={{ code: "auto", name: "Auto" }}
onSelect={handleSelect}
selectedCountry={selectedCountry}
/>
{languages.map((language) => (
<LanguageButton
key={language.code}
language={language}
onSelect={handleSelect}
selectedCountry={selectedCountry}
/>
))}
</div>
)}
</motion.button>
<motion.div
className="pointer-events-none absolute top-1/2 right-3 z-20 mt-0.5 -translate-y-1/2"
initial={{ opacity: 1 }}
animate={{ opacity: expanded ? 0 : 1 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<ChevronDown className="text-muted-foreground size-4" />
</motion.div>
</div>
);
}
function LanguageButton({
language,
onSelect,
selectedCountry,
}: {
language: Language;
onSelect: ({
code,
e,
}: {
code: string;
e: React.MouseEvent<HTMLButtonElement>;
}) => void;
selectedCountry: string;
}) {
return (
<button
type="button"
className="text-foreground hover:text-foreground/75 flex cursor-pointer items-center gap-2"
onClick={(e) => onSelect({ code: language.code, e })}
>
{language.code === "auto" ? (
<Globe className="!size-[1.0rem]" />
) : (
<ReactCountryFlag
countryCode={language.code}
svg
style={{ width: "1.05rem", height: "1.05rem" }}
/>
)}
<span className="pt-[0.05rem]">{language.name}</span>
</button>
);
}
+1 -1
View File
@@ -1,9 +1,9 @@
"use client";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { storageService } from "@/services/storage/storage-service";
import { toast } from "sonner";
interface StorageContextType {
isInitialized: boolean;
+2 -3
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import type * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
@@ -24,8 +24,7 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
+1 -2
View File
@@ -41,8 +41,7 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import * as React from "react";
import type * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
+1 -1
View File
@@ -1,10 +1,10 @@
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/utils/ui";
import { Button } from "./button";
+35 -28
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { forwardRef, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/utils/ui";
import { Input } from "./input";
@@ -20,8 +20,8 @@ const hexToHsv = (hex: string) => {
const diff = max - min;
let h = 0;
let s = max === 0 ? 0 : diff / max;
let v = max;
const s = max === 0 ? 0 : diff / max;
const v = max;
if (diff !== 0) {
switch (max) {
@@ -38,7 +38,7 @@ const hexToHsv = (hex: string) => {
}
h = (h * 60 + 360) % 360;
if (isNaN(h)) h = 0;
if (Number.isNaN(h)) h = 0;
return [h, s, v];
};
@@ -85,32 +85,32 @@ const hsvToHex = (h: number, s: number, v: number) => {
return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("");
};
const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
({ className, value = "FFFFFF", onChange, containerRef, ...props }, ref) => {
const [isOpen, setIsOpen] = React.useState(false);
const [isDragging, setIsDragging] = React.useState<
"saturation" | "hue" | null
>(null);
const [pickerPosition, setPickerPosition] = React.useState({
const [isOpen, setIsOpen] = useState(false);
const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>(
null,
);
const [pickerPosition, setPickerPosition] = useState({
right: 0,
bottom: 0,
});
const [internalHue, setInternalHue] = React.useState(0);
const [inputValue, setInputValue] = React.useState(value);
const [internalHue, setInternalHue] = useState(0);
const [inputValue, setInputValue] = useState(value);
const pickerRef = React.useRef<HTMLDivElement>(null);
const saturationRef = React.useRef<HTMLDivElement>(null);
const hueRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLDivElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const saturationRef = useRef<HTMLButtonElement>(null);
const hueRef = useRef<HTMLButtonElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [h, s, v] = hexToHsv(value);
const displayHue = s > 0 ? h : internalHue;
React.useEffect(() => {
useEffect(() => {
setInputValue(value);
}, [value]);
React.useEffect(() => {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
pickerRef.current &&
@@ -127,7 +127,7 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
}
}, [isOpen]);
React.useEffect(() => {
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
@@ -178,8 +178,10 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
const handleSaturationMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
const saturationElement = saturationRef.current;
if (!saturationElement) return;
setIsDragging("saturation");
const rect = saturationRef.current!.getBoundingClientRect();
const rect = saturationElement.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
const newS = x;
@@ -190,8 +192,10 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
const handleHueMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
const hueElement = hueRef.current;
if (!hueElement) return;
setIsDragging("hue");
const rect = hueRef.current!.getBoundingClientRect();
const rect = hueElement.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const newH = x * 360;
setInternalHue(newH);
@@ -236,10 +240,11 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
)}
{...props}
>
<div
<button
ref={triggerRef}
className="size-6 cursor-pointer rounded-full transition-all hover:ring-2 hover:ring-white/20"
style={{ backgroundColor: `#${value}` }}
type="button"
onClick={() => {
if (!isOpen && triggerRef.current && containerRef?.current) {
const containerRect =
@@ -274,10 +279,11 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
bottom: pickerPosition.bottom,
}}
>
<div
<button
ref={saturationRef}
className="relative mb-3 h-32 w-48 cursor-crosshair"
className="relative mb-3 h-32 w-48 cursor-crosshair appearance-none border-0 bg-transparent p-0"
style={saturationStyle}
type="button"
onMouseDown={handleSaturationMouseDown}
>
<ColorCircle
@@ -285,12 +291,13 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
color={`#${value}`}
/>
</div>
</button>
<div
<button
ref={hueRef}
className="relative h-4 w-48 cursor-pointer rounded-lg"
className="relative h-4 w-48 cursor-pointer rounded-lg appearance-none border-0 bg-transparent p-0"
style={hueStyle}
type="button"
onMouseDown={handleHueMouseDown}
>
<ColorCircle
@@ -301,7 +308,7 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
}}
color={`#${value}`}
/>
</div>
</button>
</div>,
document.body,
)}
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { DialogProps } from "@radix-ui/react-dialog";
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/utils/ui";
+9 -9
View File
@@ -20,12 +20,13 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const dropdownMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "focus:opacity-65 focus:text-accent-foreground",
destructive: "text-destructive focus:text-destructive/80",
default: "",
destructive:
"text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
},
},
defaultVariants: {
@@ -45,7 +46,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
ref={ref}
className={cn(
dropdownMenuItemVariants({ variant }),
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
"data-[state=open]:bg-muted data-[state=open]:text-foreground",
inset && "pl-8",
className,
)}
@@ -65,7 +66,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
className,
)}
{...props}
@@ -87,8 +88,7 @@ const DropdownMenuContent = React.forwardRef<
e.preventDefault();
}}
className={cn(
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
className,
)}
{...props}
@@ -181,7 +181,7 @@ const DropdownMenuLabel = React.forwardRef<
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
"px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
inset && "pl-8",
className,
)}
@@ -196,7 +196,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("bg-foreground/10 -mx-1 my-1 h-px", className)}
className={cn("bg-border/60 mx-1 my-2 h-px", className)}
{...props}
/>
));
+1 -1
View File
@@ -5,7 +5,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants";
interface FontPickerProps {
defaultValue?: FontFamily;
+4 -4
View File
@@ -1,13 +1,13 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui";
import { type Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui";
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";
+1 -1
View File
@@ -3,7 +3,7 @@
import { Eye, EyeOff, X } from "lucide-react";
import { cn } from "@/utils/ui";
import { Button } from "./button";
import { forwardRef, ComponentProps } from "react";
import { forwardRef, type ComponentProps } from "react";
import { useState } from "react";
interface InputProps extends ComponentProps<"input"> {
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/utils/ui";
import { ButtonProps, buttonVariants } from "./button";
import { type ButtonProps, buttonVariants } from "./button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
+1 -2
View File
@@ -50,8 +50,7 @@ const sheetVariants = cva(
);
interface SheetContentProps
extends
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
+725
View File
@@ -0,0 +1,725 @@
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/utils/ui";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { Button, ButtonProps } from "@/components/ui/button";
import { Button, type ButtonProps } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ReactNode, forwardRef } from "react";
import { type ReactNode, forwardRef } from "react";
import { cn } from "@/utils/ui";
interface SplitButtonProps {
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import { type VariantProps } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { toggleVariants } from "./toggle";
+1 -2
View File
@@ -39,8 +39,7 @@ const tooltipVariants = cva(
);
interface TooltipContentProps
extends
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>,
extends React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>,
VariantProps<typeof tooltipVariants> {}
const TooltipContent = React.forwardRef<
@@ -1,13 +0,0 @@
import { Language } from "@/components/language-select";
export const LANGUAGES: Language[] = [
{ code: "US", name: "English" },
{ code: "ES", name: "Spanish" },
{ code: "IT", name: "Italian" },
{ code: "FR", name: "French" },
{ code: "DE", name: "German" },
{ code: "PT", name: "Portuguese" },
{ code: "RU", name: "Russian" },
{ code: "JP", name: "Japanese" },
{ code: "CN", name: "Chinese" },
];
+1 -1
View File
@@ -1,4 +1,4 @@
import { ExportOptions } from "@/types/export";
import type { ExportOptions } from "@/types/export";
export const DEFAULT_EXPORT_OPTIONS = {
format: "mp4",
@@ -0,0 +1,11 @@
export const LANGUAGES = [
{ code: "en", name: "English" },
{ code: "es", name: "Spanish" },
{ code: "it", name: "Italian" },
{ code: "fr", name: "French" },
{ code: "de", name: "German" },
{ code: "pt", name: "Portuguese" },
{ code: "ru", name: "Russian" },
{ code: "ja", name: "Japanese" },
{ code: "zh", name: "Chinese" },
] as const;
+1 -1
View File
@@ -1,4 +1,4 @@
import { TCanvasSize } from "@/types/project";
import type { TCanvasSize } from "@/types/project";
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
{ width: 1920, height: 1080 },
+1 -1
View File
@@ -1,4 +1,4 @@
import { TextElement } from "@/types/timeline";
import type { TextElement } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "./timeline-constants";
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
@@ -1,7 +1,25 @@
import { LANGUAGES } from "@/constants/language-constants";
import type {
TranscriptionModel,
TranscriptionModelId,
} from "@/types/transcription";
import type { LanguageCode } from "@/types/language";
const SUPPORTED_TRANSCRIPTION_LANGS: ReadonlyArray<LanguageCode> = [
"en",
"es",
"it",
"fr",
"de",
"pt",
"ru",
"ja",
"zh",
];
export const TRANSCRIPTION_LANGUAGES = LANGUAGES.filter((language) =>
SUPPORTED_TRANSCRIPTION_LANGS.includes(language.code),
);
export const TRANSCRIPTION_MODELS: TranscriptionModel[] = [
{
+1 -1
View File
@@ -1,4 +1,4 @@
import { Command } from "@/lib/commands";
import type { Command } from "@/lib/commands";
export class CommandManager {
private history: Command[] = [];
+54 -35
View File
@@ -157,6 +157,13 @@ export class ProjectManager {
}
await this.editor.media.loadProjectMedia({ projectId: id });
if (!project.metadata.thumbnail) {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
if (didUpdateThumbnail) {
await this.saveCurrentProject();
}
}
} catch (error) {
console.error("Failed to load project:", error);
throw error;
@@ -381,41 +388,9 @@ export class ProjectManager {
if (!this.active) return;
try {
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const allElements = tracks.flatMap(
(track): TimelineElement[] => track.elements,
);
const sortedElements = allElements.sort(
(a, b) => a.startTime - b.startTime,
);
const firstElement = sortedElements[0];
if (
firstElement &&
(firstElement.type === "video" || firstElement.type === "image")
) {
const mediaAsset = mediaAssets.find(
(asset) => asset.id === firstElement.mediaId,
);
if (mediaAsset) {
let thumbnailDataUrl: string | undefined;
if (mediaAsset.type === "video" && mediaAsset.file) {
thumbnailDataUrl = await generateThumbnail({
videoFile: mediaAsset.file,
timeInSeconds: 1,
});
} else if (mediaAsset.type === "image" && mediaAsset.url) {
thumbnailDataUrl = mediaAsset.thumbnailUrl || mediaAsset.url;
}
if (thumbnailDataUrl && !thumbnailDataUrl.startsWith("blob:")) {
await this.updateThumbnail({ thumbnail: thumbnailDataUrl });
}
}
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
if (didUpdateThumbnail) {
await this.editor.save.flush();
}
} catch (error) {
console.error("Failed to generate project thumbnail on exit:", error);
@@ -520,6 +495,50 @@ export class ProjectManager {
return () => this.listeners.delete(listener);
}
private async updateThumbnailFromTimeline(): Promise<boolean> {
if (!this.active) return false;
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const allElements = tracks.flatMap(
(track): TimelineElement[] => track.elements,
);
const sortedElements = allElements.sort(
(a, b) => a.startTime - b.startTime,
);
const firstElement = sortedElements[0];
if (!firstElement) return false;
if (firstElement.type !== "video" && firstElement.type !== "image") {
return false;
}
const mediaAsset = mediaAssets.find(
(asset) => asset.id === firstElement.mediaId,
);
if (!mediaAsset) return false;
let thumbnailDataUrl: string | undefined;
if (mediaAsset.type === "video" && mediaAsset.file) {
thumbnailDataUrl = await generateThumbnail({
videoFile: mediaAsset.file,
timeInSeconds: 1,
});
}
if (mediaAsset.type === "image" && mediaAsset.url) {
thumbnailDataUrl = mediaAsset.thumbnailUrl || mediaAsset.url;
}
if (!thumbnailDataUrl || thumbnailDataUrl.startsWith("blob:")) {
return false;
}
await this.updateThumbnail({ thumbnail: thumbnailDataUrl });
return true;
}
private updateMetadata(project: TProject): void {
const index = this.savedProjects.findIndex(
(p) => p.id === project.metadata.id,
@@ -26,7 +26,7 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
} from "@/lib/commands/timeline";
import { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
@@ -1,19 +1,19 @@
import { useEffect, useRef, useCallback } from "react";
import {
import { useCallback, useEffect, useRef } from "react";
import type {
TAction,
TActionFunc,
TActionHandlerOptions,
TArgOfAction,
TInvocationTrigger,
bindAction,
unbindAction,
} from "@/lib/actions";
import { bindAction, unbindAction } from "@/lib/actions";
export function useActionHandler<A extends TAction>(
action: A,
handler: TActionFunc<A>,
isActive: TActionHandlerOptions,
) {
const handlerRef = useRef(handler);
const handlerRef = useRef<TActionFunc<A>>(handler);
const isBoundRef = useRef(false);
useEffect(() => {
@@ -21,8 +21,12 @@ export function useActionHandler<A extends TAction>(
}, [handler]);
const stableHandler = useCallback(
(args: any, trigger?: TInvocationTrigger) => {
(handlerRef.current as any)(args, trigger);
(...parameters: [TArgOfAction<A>, TInvocationTrigger?]) => {
(
handlerRef.current as (
...handlerParameters: [TArgOfAction<A>, TInvocationTrigger?]
) => void
)(...parameters);
},
[],
) as TActionFunc<A>;
@@ -45,21 +49,4 @@ export function useActionHandler<A extends TAction>(
isBoundRef.current = false;
};
}, [action, stableHandler, isActive]);
useEffect(() => {
if (isActive && typeof isActive === "object" && "current" in isActive) {
const interval = setInterval(() => {
const shouldBind = isActive.current;
if (shouldBind !== isBoundRef.current) {
if (shouldBind) {
bindAction(action, stableHandler);
} else {
unbindAction(action, stableHandler);
}
isBoundRef.current = shouldBind;
}
}, 100);
return () => clearInterval(interval);
}
}, [action, stableHandler, isActive]);
}
@@ -151,10 +151,11 @@ function getDragDropTarget({
});
}
interface StartDragParams extends Omit<
interface StartDragParams
extends Omit<
ElementDragState,
"isDragging" | "currentTime" | "currentMouseY"
> {
> {
initialCurrentTime: number;
initialCurrentMouseY: number;
}
@@ -381,12 +382,12 @@ export function useElementInteraction({
dragState.isDragging,
dragState.clickOffsetTime,
dragState.elementId,
dragState.startMouseY,
dragState.trackId,
zoomLevel,
isElementSelected,
selectElement,
editor.project,
editor.playback,
timelineRef,
tracksScrollRef,
tracksContainerRef,
@@ -474,6 +475,7 @@ export function useElementInteraction({
}, [
dragState.isDragging,
dragState.elementId,
dragState.startMouseY,
dragState.trackId,
dragState.currentTime,
zoomLevel,
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { snapTimeToFrame } from "@/lib/time";
import { EditorCore } from "@/core";
import {
@@ -10,7 +10,6 @@ interface UseEdgeAutoScrollParams {
maxScrollSpeed?: number;
}
// Provides smooth edge auto-scrolling for horizontal timeline interactions.
export function useEdgeAutoScroll({
isActive,
getMouseClientX,
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback, useEffect, useState } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
import { useEditor } from "../use-editor";
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import type { TimelineTrack } from "@/types/timeline";
import { useState, useEffect } from "react";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
@@ -96,7 +96,7 @@ export function useTimelineDragDrop({
const isExternal =
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
let elementType = getElementType({ dataTransfer: e.dataTransfer });
const elementType = getElementType({ dataTransfer: e.dataTransfer });
if (!elementType && hasFiles && isExternal) {
setDropTarget(null);
@@ -1,5 +1,5 @@
import { useCallback } from "react";
import { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
export interface SnapPoint {
@@ -1,9 +1,9 @@
import {
useState,
type WheelEvent as ReactWheelEvent,
type RefObject,
useCallback,
useEffect,
type RefObject,
type WheelEvent as ReactWheelEvent,
useState,
} from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+27 -11
View File
@@ -1,22 +1,38 @@
import { useEffect, useMemo, useReducer } from "react";
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { EditorCore } from "@/core";
export function useEditor(): EditorCore {
const editor = useMemo(() => EditorCore.getInstance(), []);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const versionRef = useRef(0);
const subscribe = useCallback(
(onStoreChange: () => void) => {
const handleStoreChange = () => {
versionRef.current += 1;
onStoreChange();
};
useEffect(() => {
const unsubscribers = [
editor.playback.subscribe(forceUpdate),
editor.timeline.subscribe(forceUpdate),
editor.scenes.subscribe(forceUpdate),
editor.project.subscribe(forceUpdate),
editor.media.subscribe(forceUpdate),
editor.renderer.subscribe(forceUpdate),
editor.playback.subscribe(handleStoreChange),
editor.timeline.subscribe(handleStoreChange),
editor.scenes.subscribe(handleStoreChange),
editor.project.subscribe(handleStoreChange),
editor.media.subscribe(handleStoreChange),
editor.renderer.subscribe(handleStoreChange),
];
return () => unsubscribers.forEach((unsub) => unsub());
}, [editor]);
return () => {
for (const unsubscribe of unsubscribers) {
unsubscribe();
}
};
},
[editor],
);
const getSnapshot = useCallback(() => versionRef.current, []);
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return editor;
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
+2 -2
View File
@@ -2,11 +2,11 @@ import { useEffect, useRef } from "react";
export function useRafLoop(callback: ({ time }: { time: number }) => void) {
const requestRef = useRef<number>(0);
const previousTimeRef = useRef<number>(0);
const previousTimeRef = useRef<number | null>(null);
useEffect(() => {
const loop = ({ time }: { time: number }) => {
if (previousTimeRef.current !== undefined) {
if (previousTimeRef.current !== null) {
const deltaTime = time - previousTimeRef.current;
callback({ time: deltaTime });
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { betterAuth, RateLimit } from "better-auth";
import { betterAuth, type RateLimit } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Redis } from "@upstash/redis";
import { db } from "@/lib/db";
+1 -1
View File
@@ -1,5 +1,5 @@
import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
import { ExportFormat } from "@/types/export";
import type { ExportFormat } from "@/types/export";
export function getExportMimeType({
format,
+1 -1
View File
@@ -50,7 +50,7 @@ type ExtentKeyword = { type: "extent-keyword"; value: string; at?: Position };
type RadialOrientation = Shape | ExtentKeyword | DefaultRadial;
const gradientLayerPattern =
/^(?:\-(webkit|o|ms|moz)\-)?(linear-gradient|repeating-linear-gradient|radial-gradient|repeating-radial-gradient)/i;
/^(?:-(webkit|o|ms|moz)-)?(linear-gradient|repeating-linear-gradient|radial-gradient|repeating-radial-gradient)/i;
export function drawCssBackground({
ctx,
+8 -8
View File
@@ -87,32 +87,32 @@ type Tokens = {
};
const tokens: Tokens = {
linearGradient: /^(\-(webkit|o|ms|moz)\-)?(linear\-gradient)/i,
linearGradient: /^(-(webkit|o|ms|moz)-)?(linear-gradient)/i,
repeatingLinearGradient:
/^(\-(webkit|o|ms|moz)\-)?(repeating\-linear\-gradient)/i,
radialGradient: /^(\-(webkit|o|ms|moz)\-)?(radial\-gradient)/i,
/^(-(webkit|o|ms|moz)-)?(repeating-linear-gradient)/i,
radialGradient: /^(-(webkit|o|ms|moz)-)?(radial-gradient)/i,
repeatingRadialGradient:
/^(\-(webkit|o|ms|moz)\-)?(repeating\-radial\-gradient)/i,
/^(-(webkit|o|ms|moz)-)?(repeating-radial-gradient)/i,
sideOrCorner:
/^to (left (top|bottom)|right (top|bottom)|top (left|right)|bottom (left|right)|left|right|top|bottom)/i,
extentKeywords:
/^(closest\-side|closest\-corner|farthest\-side|farthest\-corner|contain|cover)/,
/^(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)/,
positionKeywords: /^(left|center|right|top|bottom)/i,
pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/,
percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))\%/,
percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))%/,
emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/,
angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/,
radianValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))rad/,
startCall: /^\(/,
endCall: /^\)/,
comma: /^,/,
hexColor: /^\#([0-9a-fA-F]+)/,
hexColor: /^#([0-9a-fA-F]+)/,
literalColor: /^([a-zA-Z]+)/,
rgbColor: /^rgb/i,
rgbaColor: /^rgba/i,
varColor: /^var/i,
calcValue: /^calc/i,
variableName: /^(--[a-zA-Z0-9-,\s\#]+)/,
variableName: /^(--[a-zA-Z0-9-,\s#]+)/,
number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/,
hslColor: /^hsl/i,
hslaColor: /^hsla/i,
+1 -1
View File
@@ -173,7 +173,7 @@ export function buildIconSvgUrl(
}
const queryString = urlParams.toString();
return `${host}/${prefix}/${name}.svg${queryString ? "?" + queryString : ""}`;
return `${host}/${prefix}/${name}.svg${queryString ? `?${queryString}` : ""}`;
}
export function getIconSvgUrl(
+10 -2
View File
@@ -87,7 +87,10 @@ export const extractTimelineAudio = async ({
totalSamples,
});
} catch (error) {
console.warn(`Failed to process audio source ${source.file.name}:`, error);
console.warn(
`Failed to process audio source ${source.file.name}:`,
error,
);
}
}
@@ -117,7 +120,12 @@ async function decodeAndMixAudioSource({
mixBuffers,
totalSamples,
}: {
source: { file: File; startTime: number; duration: number; trimStart: number };
source: {
file: File;
startTime: number;
duration: number;
trimStart: number;
};
mixBuffers: Float32Array[];
totalSamples: number;
}): Promise<void> {
+8 -2
View File
@@ -1,5 +1,5 @@
import { toast } from "sonner";
import { MediaAsset } from "@/types/assets";
import type { MediaAsset } from "@/types/assets";
import { getMediaTypeFromFile } from "@/lib/media/media-utils";
import { getVideoInfo } from "./mediabunny";
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
@@ -64,9 +64,13 @@ export async function generateThumbnail({
throw new Error("Could not get canvas context");
}
try {
frame.draw(ctx, 0, 0, width, height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.8);
return dataUrl;
} finally {
frame.close();
}
}
export async function processMediaAssets({
@@ -108,7 +112,9 @@ export async function processMediaAssets({
duration = videoInfo.duration;
width = videoInfo.width;
height = videoInfo.height;
fps = videoInfo.fps;
fps = Number.isFinite(videoInfo.fps)
? Math.round(videoInfo.fps * 1000) / 1000
: undefined;
thumbnailUrl = await generateThumbnail({
videoFile: file,
+1 -1
View File
@@ -1,4 +1,4 @@
import { TScene } from "@/types/timeline";
import type { TScene } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
+1 -1
View File
@@ -1,6 +1,6 @@
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
import type {
CreateTimelineElement,
CreateStickerElement,
CreateUploadAudioElement,
+1 -1
View File
@@ -1,4 +1,4 @@
import { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/types/timeline";
export * from "./track-utils";
export * from "./element-utils";
+1 -1
View File
@@ -3,7 +3,7 @@ import {
ALL_FORMATS,
BlobSource,
CanvasSink,
WrappedCanvas,
type WrappedCanvas,
} from "mediabunny";
interface VideoSinkData {
@@ -1,4 +1,4 @@
import { BaseNode } from "./nodes/base-node";
import type { BaseNode } from "./nodes/base-node";
export type CanvasRendererParams = {
width: number;
@@ -1,4 +1,4 @@
import { CanvasRenderer } from "../canvas-renderer";
import type { CanvasRenderer } from "../canvas-renderer";
export type BaseNodeParams = object | undefined;
@@ -1,5 +1,5 @@
import { drawCssBackground } from "@/lib/gradients";
import { CanvasRenderer } from "../canvas-renderer";
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
export type ColorNodeParams = {
@@ -1,6 +1,6 @@
import { CanvasRenderer } from "../canvas-renderer";
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import { BaseMediaNodeParams } from "./video-node";
import type { BaseMediaNodeParams } from "./video-node";
const IMAGE_EPSILON = 1 / 1000;
@@ -1,6 +1,6 @@
import { CanvasRenderer } from "../canvas-renderer";
import { BaseNode, BaseNodeParams } from "./base-node";
import { Transform } from "@/types/timeline";
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode, type BaseNodeParams } from "./base-node";
import type { Transform } from "@/types/timeline";
const STICKER_EPSILON = 1 / 1000;
@@ -1,6 +1,6 @@
import { CanvasRenderer } from "../canvas-renderer";
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import { TextElement } from "@/types/timeline";
import type { TextElement } from "@/types/timeline";
export type TextNodeParams = TextElement & {
canvasCenter: { x: number; y: number };

Some files were not shown because too many files have changed in this diff Show More