lots more icons to do but

This commit is contained in:
Maze Winther
2026-01-24 15:53:46 +01:00
parent 33f453efc7
commit 954160d03b
39 changed files with 519 additions and 710 deletions
+2
View File
@@ -21,6 +21,8 @@
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
+1 -35
View File
@@ -1,12 +1,9 @@
import { OcGithubIcon } 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 { Card, CardContent } from "@/components/ui/card";
import { EXTERNAL_TOOLS, SOCIAL_LINKS } from "@/constants/site-constants";
import { EXTERNAL_TOOLS } from "@/constants/site-constants";
import { BasePage } from "../base-page";
export const metadata: Metadata = {
@@ -87,7 +84,6 @@ export default async function ContributorsPage() {
{otherContributors.length > 0 && (
<AllContributorsSection contributors={otherContributors} />
)}
{contributors.length === 0 && <EmptyState />}
<ExternalToolsSection />
<GitHubContributeSection
title="Join the community"
@@ -210,36 +206,6 @@ function AllContributorsSection({
);
}
function EmptyState() {
return (
<div className="flex flex-col gap-8 py-20 text-center">
<div className="flex flex-col gap-6">
<div className="bg-muted/50 mx-auto flex size-20 items-center justify-center rounded-full">
<OcGithubIcon className="text-muted-foreground size-8" />
</div>
<div className="flex flex-col gap-3">
<h3 className="text-xl font-medium">No contributors found</h3>
<p className="text-muted-foreground mx-auto max-w-md">
Unable to load contributors at the moment. Check back later or view
on GitHub.
</p>
</div>
</div>
<Link
href={`${SOCIAL_LINKS.github}/graphs/contributors`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" className="gap-2">
<OcGithubIcon />
View on GitHub
<ExternalLink />
</Button>
</Link>
</div>
);
}
function ExternalToolsSection() {
return (
<div className="flex flex-col gap-10">
+38 -26
View File
@@ -1,16 +1,5 @@
"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";
@@ -41,6 +30,18 @@ import {
import { useEditor } from "@/hooks/use-editor";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowHorizontalIcon,
ArrowLeft01Icon,
Calendar04Icon,
Delete02Icon,
MultiplicationSignIcon,
PlusSignIcon,
Search01Icon,
SortingNineOneIcon,
Video01Icon,
} from "@hugeicons/core-free-icons";
export default function ProjectsPage() {
const [isSelectionMode, setIsSelectionMode] = useState(false);
@@ -160,7 +161,7 @@ export default function ProjectsPage() {
href="/"
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
>
<ChevronLeft className="size-5! shrink-0" />
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-5! shrink-0" />
<span className="text-sm font-medium">Back</span>
</Link>
<div className="block md:hidden">
@@ -171,7 +172,7 @@ export default function ProjectsPage() {
size="sm"
onClick={handleCancelSelection}
>
<X />
<HugeiconsIcon icon={MultiplicationSignIcon} />
Cancel
</Button>
{selectedProjects.size > 0 && (
@@ -180,7 +181,7 @@ export default function ProjectsPage() {
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 />
<HugeiconsIcon icon={Delete02Icon} />
Delete ({selectedProjects.size})
</Button>
)}
@@ -210,7 +211,7 @@ export default function ProjectsPage() {
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancelSelection}>
<X />
<HugeiconsIcon icon={MultiplicationSignIcon} />
Cancel
</Button>
<Button
@@ -218,7 +219,7 @@ export default function ProjectsPage() {
disabled={selectedProjects.size === 0}
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 />
<HugeiconsIcon icon={Delete02Icon} />
Delete {selectedProjects.size} projects
</Button>
</div>
@@ -257,9 +258,8 @@ export default function ProjectsPage() {
variant="outline"
className="size-9 items-center justify-center"
>
<ArrowDown01
strokeWidth={1.5}
className="!size-[1.05rem]"
<HugeiconsIcon
icon={SortingNineOneIcon}
aria-hidden="true"
/>
</Button>
@@ -430,7 +430,10 @@ function ProjectCard({
/>
) : (
<div className="bg-muted/50 flex size-full items-center justify-center">
<Video className="text-muted-foreground size-12 shrink-0" />
<HugeiconsIcon
icon={Video01Icon}
className="text-muted-foreground size-12 shrink-0"
/>
</div>
)}
</div>
@@ -458,7 +461,10 @@ function ProjectCard({
}`}
onClick={(event) => event.preventDefault()}
>
<MoreHorizontal aria-hidden="true" />
<HugeiconsIcon
icon={ArrowHorizontalIcon}
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -506,7 +512,7 @@ function ProjectCard({
<div className="space-y-1">
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<Calendar className="size-4!" />
<HugeiconsIcon icon={Calendar04Icon} className="size-4" />
<span>Created {formatDate({ date: project.createdAt })}</span>
</div>
</div>
@@ -575,7 +581,7 @@ function ProjectsLoader() {
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
<Plus />
<HugeiconsIcon icon={PlusSignIcon} />
<span className="text-sm font-medium">New project</span>
</Button>
);
@@ -596,7 +602,10 @@ function EmptyState({
<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" />
<HugeiconsIcon
icon={Search01Icon}
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">
@@ -614,7 +623,10 @@ function EmptyState({
<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" />
<HugeiconsIcon
icon={Video01Icon}
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">
@@ -623,7 +635,7 @@ function EmptyState({
</p>
</div>
<Button size="lg" className="gap-2" onClick={onCreateProject}>
<Plus />
<HugeiconsIcon icon={PlusSignIcon} />
Create your first project
</Button>
</div>
+56 -46
View File
@@ -1,17 +1,18 @@
"use client";
import {
ArrowDown,
Clock3,
Folder,
LayoutGrid,
List,
MoreHorizontal,
Plus,
Search,
} from "lucide-react";
import Image from "next/image";
import { useEffect, useMemo, useRef, useState } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowHorizontalIcon,
Clock03Icon,
Folder03Icon,
GridViewIcon,
LeftToRightListDashIcon,
PlusSignIcon,
SearchIcon,
SortingNineOneIcon,
} from "@hugeicons/core-free-icons";
const projects = [
{
@@ -110,7 +111,7 @@ export default function ProjectsPage() {
href="/projects"
className="flex items-center gap-3 rounded-2xl bg-blue-50/50 px-4 py-3 text-sm font-medium text-[#00A3FF]"
>
<LayoutGrid className="size-5" />
<HugeiconsIcon icon={GridViewIcon} className="size-5" />
Projects
</a>
<div className="px-4 pt-6 pb-2">
@@ -122,14 +123,20 @@ export default function ProjectsPage() {
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="size-5 text-slate-400" />
<HugeiconsIcon
icon={Folder03Icon}
className="size-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="size-5 text-slate-400" />
<HugeiconsIcon
icon={Folder03Icon}
className="size-5 text-slate-400"
/>
Social media
</a>
</nav>
@@ -143,7 +150,10 @@ export default function ProjectsPage() {
<div className="flex items-center gap-4">
<div className="group relative">
<Search className="absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-slate-400" />
<HugeiconsIcon
icon={SearchIcon}
className="absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-slate-400"
/>
<input
type="text"
placeholder="Search..."
@@ -157,18 +167,22 @@ export default function ProjectsPage() {
className="rounded-full bg-slate-100 p-2 text-slate-900"
aria-label="Grid view"
>
<LayoutGrid className="size-4" aria-hidden="true">
<title>Grid view</title>
</LayoutGrid>
<HugeiconsIcon
icon={GridViewIcon}
className="size-4"
aria-hidden="true"
/>
</button>
<button
type="button"
className="rounded-full p-2 text-slate-400 hover:text-slate-600"
aria-label="List view"
>
<List className="size-4" aria-hidden="true">
<title>List view</title>
</List>
<HugeiconsIcon
icon={LeftToRightListDashIcon}
className="size-4"
aria-hidden="true"
/>
</button>
</div>
@@ -176,7 +190,7 @@ export default function ProjectsPage() {
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="size-4" />
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
New project
</button>
</div>
@@ -210,7 +224,7 @@ export default function ProjectsPage() {
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" />
<HugeiconsIcon icon={SortingNineOneIcon} className="size-3.5" />
</button>
</div>
@@ -243,9 +257,11 @@ export default function ProjectsPage() {
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="size-4" aria-hidden="true">
<title>Project menu</title>
</MoreHorizontal>
<HugeiconsIcon
icon={ArrowHorizontalIcon}
className="size-4"
aria-hidden="true"
/>
</button>
</div>
@@ -268,7 +284,11 @@ export default function ProjectsPage() {
{name}
</h3>
<div className="mt-2 flex items-center gap-2 text-slate-400">
<Clock3 className="size-4" aria-hidden="true" />
<HugeiconsIcon
icon={Clock03Icon}
className="size-4"
aria-hidden="true"
/>
<p className="text-xs font-medium">{createdAtLabel}</p>
</div>
</div>
@@ -281,42 +301,33 @@ export default function ProjectsPage() {
</main>
<style jsx global>{`
.project-card:hover .card-actions {
.project-card:hover .card-actions
opacity: 1;
}
.checkbox-wrapper:checked + div {
.checkbox-wrapper:checked + div
border-color: var(--brand-blue);
background-color: #eff6ff;
}
.controls-row.has-selection .controls-select {
.controls-row.has-selection .controls-select
opacity: 1;
}
.project-select-wrapper {
.project-select-wrapper
opacity: 0;
}
.project-card:hover .project-select-wrapper {
.project-card:hover .project-select-wrapper
opacity: 1;
}
.project-card:has(.project-select:checked) .project-select-wrapper {
.project-card:has(.project-select:checked) .project-select-wrapper
opacity: 1;
}
.multi-select .project-menu {
.multi-select .project-menu
opacity: 0 !important;
pointer-events: none;
}
.custom-checkbox {
.custom-checkbox
appearance: none;
background-color: #fff;
border: 2px solid #e2e8f0;
border-radius: 8px;
display: inline-grid;
place-content: center;
}
.custom-checkbox:checked {
.custom-checkbox:checked
background-color: var(--brand-blue);
border-color: var(--brand-blue);
}
.custom-checkbox:checked::after {
.custom-checkbox:checked::after
content: "";
width: 10px;
height: 10px;
@@ -329,7 +340,6 @@ export default function ProjectsPage() {
86% 10%,
40% 70%
);
}
`}</style>
</div>
);
+10 -6
View File
@@ -1,10 +1,11 @@
import { ExternalLink } from "lucide-react";
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Card, CardContent } from "@/components/ui/card";
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
import { HugeiconsIcon } from "@hugeicons/react";
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
export const metadata: Metadata = {
title: "Sponsors - OpenCut",
@@ -25,9 +26,9 @@ export default function SponsorsPage() {
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
Sponsors
</h1>
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed">
Support OpenCut and help us build the future of free and open-source
video editing.
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed text-pretty">
Support OpenCut and help us build the future of privacy-first video
editing.
</p>
</div>
<SponsorsGrid />
@@ -51,7 +52,7 @@ function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
href={sponsor.url}
target="_blank"
rel="noopener noreferrer"
className="size-full"
className="size-full"
>
<Card className="h-full">
<CardContent className="flex h-full flex-col justify-center gap-8 p-8">
@@ -67,7 +68,10 @@ function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
<h3 className="text-xl font-semibold group-hover:underline">
{sponsor.name}
</h3>
<ExternalLink className="text-muted-foreground size-4" />
<HugeiconsIcon
icon={LinkSquare02Icon}
className="text-muted-foreground size-4"
/>
</div>
<p className="text-muted-foreground">{sponsor.description}</p>
</div>
@@ -1,4 +1,5 @@
import { Upload } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { UploadIcon } from "@hugeicons/core-free-icons";
interface MediaDragOverlayProps {
isVisible: boolean;
@@ -34,7 +35,7 @@ export function MediaDragOverlay({
onClick={(event) => handleClick({ event })}
>
<div className="flex items-center justify-center">
<Upload className="text-foreground size-10" />
<HugeiconsIcon icon={UploadIcon} className="text-foreground size-10" />
</div>
<div className="space-y-2">
@@ -59,7 +59,7 @@ export function TabBar() {
type="button"
aria-label={tab.label}
className={cn(
"flex cursor-pointer flex-col items-center gap-0.5",
"flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75",
activeTab === tabKey
? "text-primary !opacity-100"
: "text-muted-foreground",
@@ -72,7 +72,7 @@ export function TabBar() {
}
}}
>
<tab.icon className="size-[1.1rem]! opacity-100 hover:opacity-75" />
<tab.icon className=" " />
</button>
</TooltipTrigger>
<TooltipContent
@@ -13,7 +13,8 @@ import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
import { Loader2 } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Loading03Icon } from "@hugeicons/core-free-icons";
import type {
TranscriptionLanguage,
TranscriptionProgress,
@@ -21,6 +22,7 @@ import type {
import { transcriptionService } from "@/services/transcription";
import { decodeAudioToFloat32 } from "@/lib/media/audio";
import { buildCaptionChunks } from "@/lib/transcription/caption";
import { Spinner } from "@/components/ui/spinner";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
@@ -143,7 +145,7 @@ export function Captions() {
onClick={handleGenerateTranscript}
disabled={isProcessing}
>
{isProcessing && <Loader2 className="mr-1 animate-spin" />}
{isProcessing && <Spinner className="mr-1" />}
{isProcessing ? processingStep : "Generate transcript"}
</Button>
</div>
@@ -1,16 +1,5 @@
"use client";
import type { LucideIcon } from "lucide-react";
import {
ArrowDown01,
CloudUpload,
Grid2X2,
ImageIcon,
List,
Loader2,
Music,
Video,
} from "lucide-react";
import Image from "next/image";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -44,6 +33,17 @@ import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import type { MediaAsset } from "@/types/assets";
import type { CreateTimelineElement } from "@/types/timeline";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
GridViewIcon,
LeftToRightListDashIcon,
SortingOneNineIcon,
Image02Icon,
Loading03Icon,
MusicNote03Icon,
Video01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
export function MediaView() {
const editor = useEditor();
@@ -207,9 +207,9 @@ export function MediaView() {
className="w-full"
>
{isProcessing ? (
<Loader2 className="animate-spin" />
<HugeiconsIcon icon={Loading03Icon} className="animate-spin" />
) : (
<CloudUpload />
<HugeiconsIcon icon={CloudUploadIcon} />
)}
<span>Upload</span>
</Button>
@@ -229,12 +229,9 @@ export function MediaView() {
className="items-center justify-center"
>
{mediaViewMode === "grid" ? (
<List strokeWidth={1.5} className="!size-[1.05rem]" />
<HugeiconsIcon icon={LeftToRightListDashIcon} />
) : (
<Grid2X2
strokeWidth={1.5}
className="!size-[1.05rem]"
/>
<HugeiconsIcon icon={GridViewIcon} />
)}
</Button>
</TooltipTrigger>
@@ -255,10 +252,7 @@ export function MediaView() {
disabled={isProcessing}
className="items-center justify-center"
>
<ArrowDown01
strokeWidth={1.5}
className="!size-[1.05rem]"
/>
<HugeiconsIcon icon={SortingOneNineIcon} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -531,12 +525,12 @@ function MediaDurationLabel({ duration }: { duration?: number }) {
}
function MediaTypePlaceholder({
icon: Icon,
icon,
label,
duration,
variant,
}: {
icon: LucideIcon;
icon: IconSvgElement;
label: string;
duration?: number;
variant: "muted" | "bordered";
@@ -550,7 +544,7 @@ function MediaTypePlaceholder({
variant === "muted" ? "bg-muted/30" : "border",
)}
>
<Icon className={iconClassName} aria-hidden="true" />
<HugeiconsIcon icon={icon} className={iconClassName} />
<span className="text-xs">{label}</span>
<MediaDurationLabel duration={duration} />
</div>
@@ -596,7 +590,10 @@ function MediaPreview({
/>
{shouldShowVideoOverlay ? (
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
<Video className="size-6 text-white drop-shadow-md" />
<HugeiconsIcon
icon={Video01Icon}
className="size-6 text-white drop-shadow-md"
/>
</div>
) : null}
{shouldShowDurationBadge ? (
@@ -608,7 +605,7 @@ function MediaPreview({
return (
<MediaTypePlaceholder
icon={Video}
icon={Video01Icon}
label="Video"
duration={item.duration}
variant="muted"
@@ -619,7 +616,7 @@ function MediaPreview({
if (item.type === "audio") {
return (
<MediaTypePlaceholder
icon={Music}
icon={MusicNote03Icon}
label="Audio"
duration={item.duration}
variant="bordered"
@@ -628,7 +625,7 @@ function MediaPreview({
}
return (
<MediaTypePlaceholder icon={ImageIcon} label="Unknown" variant="muted" />
<MediaTypePlaceholder icon={Image02Icon} label="Unknown" variant="muted" />
);
}
@@ -1,6 +1,5 @@
"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";
@@ -31,6 +30,8 @@ import {
PropertyItemLabel,
PropertyItemValue,
} from "../../properties-panel/property-item";
import { HugeiconsIcon } from "@hugeicons/react";
import { DropperIcon } from "@hugeicons/core-free-icons";
export function SettingsView() {
return <ProjectSettingsTabs />;
@@ -355,7 +356,7 @@ function BackgroundView() {
<PropertyGroup title="Colors" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">
<div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border">
<PipetteIcon className="size-4" />
<HugeiconsIcon icon={DropperIcon} className="size-4" />
</div>
<BackgroundPreviews
backgrounds={colors}
@@ -1,12 +1,5 @@
"use client";
import {
HeartIcon,
ListFilter,
PauseIcon,
PlayIcon,
PlusIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -33,6 +26,14 @@ 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";
import {
FavouriteIcon,
FilterMailIcon,
PauseIcon,
PlayIcon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function SoundsView() {
return (
@@ -250,7 +251,7 @@ function SoundEffectsView() {
size="icon"
className={cn(showCommercialOnly && "text-primary")}
>
<ListFilter />
<HugeiconsIcon icon={FilterMailIcon} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
@@ -406,9 +407,9 @@ function SavedSoundsView() {
if (savedSounds.length === 0) {
return (
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
<HeartIcon
<HugeiconsIcon
icon={FavouriteIcon}
className="text-muted-foreground size-10"
strokeWidth={1.5}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium">No saved sounds</p>
@@ -527,9 +528,9 @@ function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
<div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md">
<div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" />
{isPlaying ? (
<PauseIcon className="size-5" />
<HugeiconsIcon icon={PauseIcon} className="size-5" />
) : (
<PlayIcon className="size-5" />
<HugeiconsIcon icon={PlayIcon} className="size-5" />
)}
</div>
@@ -549,7 +550,7 @@ function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
onClick={handleAddToTimeline}
title="Add to timeline"
>
<PlusIcon />
<HugeiconsIcon icon={PlusSignIcon} />
</Button>
<Button
variant="text"
@@ -562,7 +563,10 @@ function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
onClick={handleSaveClick}
title={isSaved ? "Remove from saved" : "Save sound"}
>
<HeartIcon className={`${isSaved ? "fill-current" : ""}`} />
<HugeiconsIcon
icon={FavouriteIcon}
className={`${isSaved ? "fill-current" : ""}`}
/>
</Button>
</div>
</div>
@@ -1,16 +1,5 @@
"use client";
import {
ArrowRight,
Clock,
Grid3X3,
Hash,
Loader2,
Smile,
Sparkles,
StickerIcon,
X,
} from "lucide-react";
import Image from "next/image";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
@@ -37,6 +26,17 @@ import {
import { useStickersStore } from "@/stores/stickers-store";
import type { StickerCategory } from "@/types/stickers";
import { cn } from "@/utils/ui";
import {
ArrowRightIcon,
HappyIcon,
ClockIcon,
LayoutGridIcon,
MultiplicationSignIcon,
SparklesIcon,
HashtagIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
function isStickerCategory(value: string): value is StickerCategory {
return STICKER_CATEGORIES.includes(value as StickerCategory);
@@ -57,25 +57,25 @@ export function StickersView() {
{
value: "all",
label: "All",
icon: <Grid3X3 className="size-3" />,
icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />,
content: <StickersContentView category="all" />,
},
{
value: "general",
label: "Icons",
icon: <Sparkles className="size-3" />,
icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />,
content: <StickersContentView category="general" />,
},
{
value: "brands",
label: "Brands",
icon: <Hash className="size-3" />,
icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />,
content: <StickersContentView category="brands" />,
},
{
value: "emoji",
label: "Emoji",
icon: <Smile className="size-3" />,
icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />,
content: <StickersContentView category="emoji" />,
},
]}
@@ -150,9 +150,9 @@ function CollectionGrid({
function EmptyView({ message }: { message: string }) {
return (
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
<StickerIcon
<HugeiconsIcon
icon={HappyIcon}
className="text-muted-foreground size-10"
strokeWidth={1.5}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium">No stickers found</p>
@@ -334,7 +334,10 @@ function StickersContentView({ category }: { category: StickerCategory }) {
{recentStickers.length > 0 && viewMode === "browse" && (
<div className="h-full">
<div className="mb-2 flex items-center gap-2">
<Clock className="text-muted-foreground size-4" />
<HugeiconsIcon
icon={ClockIcon}
className="text-muted-foreground size-4"
/>
<span className="text-sm font-medium">Recent</span>
<TooltipProvider>
<Tooltip>
@@ -344,7 +347,10 @@ function StickersContentView({ category }: { category: StickerCategory }) {
onClick={clearRecentStickers}
className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
>
<X className="text-muted-foreground size-3" />
<HugeiconsIcon
icon={MultiplicationSignIcon}
className="text-muted-foreground size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
@@ -366,7 +372,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full">
{isLoadingCollection ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
<Spinner className="text-muted-foreground size-6" />
</div>
) : showCollectionItems ? (
<StickerGrid
@@ -376,7 +382,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
/>
) : (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
<Spinner className="text-muted-foreground size-6" />
</div>
)}
</div>
@@ -386,7 +392,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full">
{isSearching ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
<Spinner className="text-muted-foreground size-6" />
</div>
) : searchResults?.icons.length ? (
<>
@@ -433,7 +439,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full space-y-4">
{isLoadingCollections ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
<Spinner className="text-muted-foreground size-6" />
</div>
) : (
<>
@@ -489,7 +495,7 @@ function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
<p className="font-medium">{title}</p>
<p className="text-muted-foreground text-xs">{subtitle}</p>
</div>
<ArrowRight />
<HugeiconsIcon icon={ArrowRightIcon} className="size-4" />
</Button>
);
}
@@ -593,7 +599,7 @@ function StickerItem({
/>
{isAdding && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
<Loader2 className="size-6 animate-spin text-white" />
<Spinner className="size-6 text-white" />
</div>
)}
</div>
@@ -52,7 +52,7 @@ export function DraggableItem({
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
const editor = useEditor();
const highlightClassName = "ring-2 ring-primary rounded-sm bg-primary/10";
const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`;
const handleAddToTimeline = () => {
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
@@ -111,7 +111,7 @@ export function DraggableItem({
ratio={aspectRatio}
className={cn(
"bg-panel-accent relative overflow-hidden",
isRounded && "rounded-md",
isRounded && "rounded-sm",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
)}
draggable={isDraggable}
@@ -2,7 +2,6 @@
import { Button } from "../ui/button";
import { ChevronDown } from "lucide-react";
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
import { useState } from "react";
import {
DropdownMenu,
@@ -21,7 +20,14 @@ import { ThemeToggle } from "../theme-toggle";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { OcLeftArrowIcon, OcPencilIcon, OcTrashIcon } from "@opencut/ui/icons";
import {
ArrowLeft02Icon,
Edit03Icon,
Delete02Icon,
Keyboard,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./shortcuts-dialog";
export function EditorHeader() {
return (
@@ -30,7 +36,6 @@ export function EditorHeader() {
<ProjectDropdown />
</div>
<nav className="flex items-center gap-2">
<KeyboardShortcutsHelp />
<ExportButton />
<ThemeToggle />
</nav>
@@ -39,8 +44,9 @@ export function EditorHeader() {
}
function ProjectDropdown() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [openDialog, setOpenDialog] = useState<
"delete" | "rename" | "shortcuts" | null
>(null);
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
const editor = useEditor();
@@ -78,7 +84,7 @@ function ProjectDropdown() {
error instanceof Error ? error.message : "Please try again",
});
} finally {
setIsRenameDialogOpen(false);
setOpenDialog(null);
}
}
};
@@ -94,7 +100,7 @@ function ProjectDropdown() {
error instanceof Error ? error.message : "Please try again",
});
} finally {
setIsDeleteDialogOpen(false);
setOpenDialog(null);
}
}
};
@@ -119,25 +125,32 @@ function ProjectDropdown() {
onClick={handleExit}
disabled={isExiting}
>
<OcLeftArrowIcon className="size-4" />
Projects
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Exit project
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setIsRenameDialogOpen(true)}
onClick={() => setOpenDialog("rename")}
>
<OcPencilIcon className="size-4" />
<HugeiconsIcon icon={Edit03Icon} className="size-4" />
Rename project
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="flex items-center gap-1.5"
onClick={() => setIsDeleteDialogOpen(true)}
onClick={() => setOpenDialog("delete")}
>
<OcTrashIcon className="size-4" />
Delete Project
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
Delete project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("shortcuts")}
>
<HugeiconsIcon icon={Keyboard} className="size-4" />
Keyboard shortcuts
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={SOCIAL_LINKS.discord}
@@ -152,17 +165,21 @@ function ProjectDropdown() {
</DropdownMenuContent>
</DropdownMenu>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
isOpen={openDialog === "rename"}
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "rename" : null)}
onConfirm={(newName) => handleSaveProjectName(newName)}
projectName={activeProject?.metadata.name || ""}
/>
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
isOpen={openDialog === "delete"}
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)}
onConfirm={handleDeleteProject}
projectName={activeProject?.metadata.name || ""}
/>
<ShortcutsDialog
isOpen={openDialog === "shortcuts"}
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "shortcuts" : null)}
/>
</>
);
}
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { OcTransitionUpIcon } from "@opencut/ui/icons";
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Button } from "../ui/button";
import { Label } from "../ui/label";
@@ -53,7 +54,7 @@ export function ExportButton() {
}}
>
<div className="relative flex items-center gap-1.5 rounded-[0.8rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<OcTransitionUpIcon className="z-50" />
<HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
<span className="z-50 text-[0.875rem]">Export</span>
<div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.8rem] bg-linear-to-t from-white/0 to-white/50">
<div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.8rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
@@ -4,7 +4,8 @@ import { ScrollArea } from "../../ui/scroll-area";
import { AudioProperties } from "./audio-properties";
import { VideoProperties } from "./video-properties";
import { TextProperties } from "./text-properties";
import { SquareSlashIcon } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Settings05Icon } from "@hugeicons/core-free-icons";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
@@ -29,12 +30,12 @@ export function PropertiesPanel() {
);
}
if (element.type === "audio") {
return <AudioProperties key={element.id} element={element} />;
return <AudioProperties key={element.id} _element={element} />;
}
if (element.type === "video" || element.type === "image") {
return (
<div key={element.id}>
<VideoProperties element={element} />
<VideoProperties _element={element} />
</div>
);
}
@@ -51,12 +52,13 @@ export function PropertiesPanel() {
function EmptyView() {
return (
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
<SquareSlashIcon
className="text-muted-foreground size-10"
strokeWidth={1.5}
<HugeiconsIcon
icon={Settings05Icon}
className="text-muted-foreground/75 size-10"
strokeWidth={1}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium">Its empty here</p>
<p className="text-lg font-medium">It's empty here</p>
<p className="text-muted-foreground text-sm text-balance">
Click an element on the timeline to edit its properties
</p>
@@ -1,6 +1,7 @@
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
interface PropertyItemProps {
direction?: "row" | "column";
@@ -79,7 +80,10 @@ export function PropertyGroup({
<PropertyItemLabel className={cn("cursor-pointer", titleClassName)}>
{title}
</PropertyItemLabel>
<ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} />
<HugeiconsIcon
icon={ArrowDownIcon}
className={cn("size-3", !isExpanded && "-rotate-90")}
/>
</button>
{isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
</PropertyItem>
@@ -21,7 +21,8 @@ import { ColorPicker } from "@/components/ui/color-picker";
import { cn } from "@/utils/ui";
import { capitalizeFirstLetter, uppercase } from "@/utils/strings";
import { clamp } from "@/utils/math";
import { Grid2x2 } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { LayoutGridIcon } from "@hugeicons/core-free-icons";
import {
Tooltip,
TooltipContent,
@@ -386,7 +387,7 @@ export function TextProperties({
}
className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
>
<Grid2x2
<HugeiconsIcon icon={LayoutGridIcon}
className={cn(
"text-foreground",
element.backgroundColor === "transparent" &&
@@ -8,7 +8,7 @@ import {
useKeyboardShortcutsHelp,
} from "@/hooks/use-keyboard-shortcuts-help";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { Button } from "./ui/button";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
@@ -16,11 +16,15 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
} from "../ui/dialog";
export function KeyboardShortcutsHelp() {
const [open, setOpen] = useState(false);
export function ShortcutsDialog({
isOpen,
onOpenChange,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [recordingShortcut, setRecordingShortcut] =
useState<KeyboardShortcut | null>(null);
@@ -35,7 +39,6 @@ export function KeyboardShortcutsHelp() {
isRecording,
} = useKeybindingsStore();
// Get shortcuts from centralized hook
const { shortcuts } = useKeyboardShortcutsHelp();
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
@@ -49,7 +52,6 @@ export function KeyboardShortcutsHelp() {
const keyString = getKeybindingString(e);
if (keyString) {
// Auto-save the new keybinding
const conflict = validateKeybinding(
keyString,
recordingShortcut.action,
@@ -62,11 +64,11 @@ export function KeyboardShortcutsHelp() {
return;
}
// Remove old keybindings for this action
const oldKeys = getKeybindingsForAction(recordingShortcut.action);
oldKeys.forEach((key) => removeKeybinding(key));
for (const key of oldKeys) {
removeKeybinding(key);
}
// Add new keybinding
updateKeybinding(keyString, recordingShortcut.action);
setIsRecording(false);
@@ -103,18 +105,12 @@ export function KeyboardShortcutsHelp() {
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="text" size="sm" className="gap-2">
<Keyboard />
Shortcuts
</Button>
</DialogTrigger>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
<DialogHeader className="flex-shrink-0 p-6 pb-0">
<DialogHeader className="flex-shrink-0 gap-2 p-6 pb-0">
<DialogTitle className="flex items-center gap-2">
<Keyboard className="size-5" />
Keyboard Shortcuts
Keyboard shortcuts
</DialogTitle>
<DialogDescription>
Speed up your video editing workflow with these keyboard shortcuts.
@@ -123,13 +119,13 @@ export function KeyboardShortcutsHelp() {
</DialogHeader>
<div className="scrollbar-thin flex-grow overflow-y-auto">
<div className="space-y-6 p-6 pt-2">
<div className="flex flex-col gap-6 p-6 pt-2">
{categories.map((category) => (
<div key={category} className="flex flex-col gap-1">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{category}
</h3>
<div className="space-y-1">
<div className="flex flex-col gap-1">
{shortcuts
.filter((shortcut) => shortcut.category === category)
.map((shortcut) => (
@@ -139,7 +135,7 @@ export function KeyboardShortcutsHelp() {
isRecording={
shortcut.action === recordingShortcut?.action
}
onStartRecording={handleStartRecording}
onStartRecording={() => handleStartRecording(shortcut)}
/>
))}
</div>
@@ -164,9 +160,8 @@ function ShortcutItem({
}: {
shortcut: KeyboardShortcut;
isRecording: boolean;
onStartRecording: (shortcut: KeyboardShortcut) => void;
onStartRecording: (params: { shortcut: KeyboardShortcut }) => void;
}) {
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
const displayKeys = shortcut.keys.filter((key: string) => {
if (
key.includes("Cmd") &&
@@ -195,7 +190,7 @@ function ShortcutItem({
<EditableShortcutKey
key={keyId}
isRecording={isRecording}
onStartRecording={() => onStartRecording(shortcut)}
onStartRecording={() => onStartRecording({ shortcut })}
>
{keyPart}
</EditableShortcutKey>
@@ -1,7 +1,8 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { useEditor } from "@/hooks/use-editor";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { Bookmark } from "lucide-react";
import { Bookmark02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
interface TimelineBookmarksRowProps {
zoomLevel: number;
@@ -84,10 +85,10 @@ export function TimelineBookmark({
onClick={(event) => handleBookmarkClick({ event })}
>
<div className="text-primary absolute top-[-1px] left-[-5px]">
<Bookmark
<HugeiconsIcon
icon={Bookmark02Icon}
aria-hidden="true"
className="fill-primary size-3"
focusable="false"
/>
</div>
</button>
@@ -2,14 +2,13 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Eye,
EyeOff,
VolumeOff,
Volume2,
type LucideIcon,
EyeIcon,
Trash2,
} from "lucide-react";
Delete02Icon,
ViewIcon,
ViewOffSlashIcon,
VolumeHighIcon,
VolumeOffIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import {
ContextMenu,
ContextMenuContent,
@@ -133,6 +132,7 @@ export function Timeline() {
selectionBox,
handleMouseDown: handleSelectionMouseDown,
isSelecting,
shouldIgnoreClick,
} = useSelectionBox({
containerRef: tracksContainerRef,
onSelectionComplete: (elements) => {
@@ -267,17 +267,12 @@ export function Timeline() {
}}
>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{/* Debug main track */}
{/* {isMainTrack(track) && (
<div className="size-2 rounded-full bg-red-500" />
)} */}
{canTracktHaveAudio(track) && (
<TrackToggleIcon
isOff={track.muted}
icons={{
on: Volume2,
off: VolumeOff,
on: VolumeHighIcon,
off: VolumeOffIcon,
}}
onClick={() =>
editor.timeline.toggleTrackMute({
@@ -291,8 +286,8 @@ export function Timeline() {
<TrackToggleIcon
isOff={track.hidden}
icons={{
on: Eye,
off: EyeOff,
on: ViewIcon,
off: ViewOffSlashIcon,
}}
onClick={() =>
editor.timeline.toggleTrackVisibility({
@@ -397,6 +392,7 @@ export function Timeline() {
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
onTrackMouseDown={handleSelectionMouseDown}
shouldIgnoreClick={shouldIgnoreClick}
/>
</div>
</ContextMenuTrigger>
@@ -409,7 +405,7 @@ export function Timeline() {
});
}}
>
<Volume2 />
<HugeiconsIcon icon={VolumeHighIcon} />
<span>
{canTracktHaveAudio(track) && track.muted
? "Unmute track"
@@ -424,7 +420,7 @@ export function Timeline() {
});
}}
>
<EyeIcon />
<HugeiconsIcon icon={ViewIcon} />
<span>
{canTrackBeHidden(track) && track.hidden
? "Show track"
@@ -440,7 +436,7 @@ export function Timeline() {
}}
variant="destructive"
>
<Trash2 />
<HugeiconsIcon icon={Delete02Icon} />
Delete track
</ContextMenuItem>
</ContextMenuContent>
@@ -467,20 +463,22 @@ function TrackToggleIcon({
}: {
isOff: boolean;
icons: {
on: LucideIcon;
off: LucideIcon;
on: IconSvgElement;
off: IconSvgElement;
};
onClick: () => void;
}) {
return (
<>
{isOff ? (
<icons.off
<HugeiconsIcon
icon={icons.off}
className="text-destructive size-4 cursor-pointer"
onClick={onClick}
/>
) : (
<icons.on
<HugeiconsIcon
icon={icons.on}
className="text-muted-foreground size-4 cursor-pointer"
onClick={onClick}
/>
@@ -213,10 +213,6 @@ export function TimelineElement({
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
)}
<ContextMenuItem disabled>
<ArrowUpDown className="mr-2 size-4" />
Move to track (Coming soon)
</ContextMenuItem>
{selectedElements.length === 1 && hasMediaId(element) && (
<>
<ContextMenuItem
@@ -289,7 +285,6 @@ function ElementInner({
className="absolute inset-0 size-full cursor-pointer"
onClick={(e) => onElementClick(e, element)}
onMouseDown={(e) => onElementMouseDown(e, element)}
onContextMenu={(e) => onElementMouseDown(e, element)}
>
<div className="absolute inset-0 flex h-full items-center">
<ElementContent
@@ -300,7 +295,9 @@ function ElementInner({
/>
</div>
{(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && (
{(hasAudio
? isMuted
: canElementBeHidden(element) && element.hidden) && (
<div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
{hasAudio ? (
<VolumeX className="size-6 text-white" />
@@ -381,8 +378,8 @@ function ElementContent({
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
alt={element.name}
className="size-5 shrink-0"
width={20}
height={20}
width={20}
height={20}
unoptimized
/>
<span className="truncate text-xs text-white">{element.name}</span>
@@ -7,21 +7,8 @@ import {
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import {
Pause,
Play,
SkipBack,
Bookmark,
Magnet,
Link,
ZoomOut,
ZoomIn,
Copy,
Trash2,
Snowflake,
ArrowLeftToLine,
ArrowRightToLine,
SplitSquareHorizontal,
Scissors,
LayersIcon,
} from "lucide-react";
import {
@@ -38,6 +25,28 @@ import { ScenesView } from "../scenes-view";
import { type TAction, invokeAction } from "@/lib/actions";
import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Bookmark02Icon,
Delete02Icon,
SnowIcon,
ScissorIcon,
MagnetIcon,
Link04Icon,
SearchAddIcon,
SearchMinusIcon,
PauseIcon,
PlayIcon,
Copy01Icon,
ArrowLeft03Icon,
ArrowRight03Icon,
LayoutAlignLeftIcon,
LayoutAlignRightIcon,
AlignLeftIcon,
AlignRightIcon,
Layers01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function TimelineToolbar({
zoomLevel,
@@ -60,18 +69,20 @@ export function TimelineToolbar({
};
return (
<div className="flex h-10 items-center justify-between border-b px-2 py-1">
<ToolbarLeftSection />
<ScrollArea className="scrollbar-hidden">
<div className="flex h-10 items-center justify-between border-b px-2 py-1">
<ToolbarLeftSection />
<SceneSelector />
<SceneSelector />
<ToolbarRightSection
zoomLevel={zoomLevel}
minZoom={minZoom}
onZoomChange={(zoom) => setZoomLevel({ zoom })}
onZoom={handleZoom}
/>
</div>
<ToolbarRightSection
zoomLevel={zoomLevel}
minZoom={minZoom}
onZoomChange={(zoom) => setZoomLevel({ zoom })}
onZoom={handleZoom}
/>
</div>
</ScrollArea>
);
}
@@ -96,7 +107,13 @@ function ToolbarLeftSection() {
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<ToolbarButton
icon={isPlaying ? <Pause /> : <Play />}
icon={
isPlaying ? (
<HugeiconsIcon icon={PauseIcon} />
) : (
<HugeiconsIcon icon={PlayIcon} />
)
}
tooltip={isPlaying ? "Pause" : "Play"}
onClick={({ event }) =>
handleAction({ action: "toggle-play", event })
@@ -109,14 +126,14 @@ function ToolbarLeftSection() {
onClick={({ event }) => handleAction({ action: "goto-start", event })}
/>
<div className="bg-border mx-2 h-10 w-px" />
<div className="bg-border mx-1 h-6 w-px" />
<TimeDisplay />
<div className="bg-border mx-1 h-10 w-px" />
<div className="bg-border mx-1 h-6 w-px" />
<ToolbarButton
icon={<Scissors />}
icon={<HugeiconsIcon icon={ScissorIcon} />}
tooltip="Split element"
onClick={({ event }) =>
handleAction({ action: "split-selected", event })
@@ -124,18 +141,18 @@ function ToolbarLeftSection() {
/>
<ToolbarButton
icon={<ArrowLeftToLine />}
tooltip="Split and keep left"
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
tooltip="Split left"
onClick={({ event }) =>
handleAction({ action: "split-selected-left", event })
handleAction({ action: "split-selected-right", event })
}
/>
<ToolbarButton
icon={<ArrowRightToLine />}
tooltip="Split and keep right"
icon={<HugeiconsIcon icon={AlignRightIcon} />}
tooltip="Split right"
onClick={({ event }) =>
handleAction({ action: "split-selected-right", event })
handleAction({ action: "split-selected-left", event })
}
/>
@@ -147,7 +164,7 @@ function ToolbarLeftSection() {
/>
<ToolbarButton
icon={<Copy />}
icon={<HugeiconsIcon icon={Copy01Icon} />}
tooltip="Duplicate element"
onClick={({ event }) =>
handleAction({ action: "duplicate-selected", event })
@@ -155,14 +172,14 @@ function ToolbarLeftSection() {
/>
<ToolbarButton
icon={<Snowflake />}
icon={<HugeiconsIcon icon={SnowIcon} />}
tooltip="Coming soon" /* freeze frame */
disabled={true}
onClick={({ event: _event }) => {}}
/>
<ToolbarButton
icon={<Trash2 />}
icon={<HugeiconsIcon icon={Delete02Icon} />}
tooltip="Delete element"
onClick={({ event }) =>
handleAction({ action: "delete-selected", event })
@@ -174,7 +191,8 @@ function ToolbarLeftSection() {
<Tooltip>
<ToolbarButton
icon={
<Bookmark
<HugeiconsIcon
icon={Bookmark02Icon}
className={currentBookmarked ? "fill-primary text-primary" : ""}
/>
}
@@ -228,7 +246,7 @@ function SceneSelector() {
<SplitButtonSeparator />
<ScenesView>
<SplitButtonRight onClick={() => {}} type="button">
<LayersIcon className="size-4" />
<HugeiconsIcon icon={Layers01Icon} className="size-4" />
</SplitButtonRight>
</ScenesView>
</SplitButton>
@@ -259,7 +277,10 @@ function ToolbarRightSection({
<TooltipProvider delayDuration={500}>
<ToolbarButton
icon={
<Magnet className={cn(snappingEnabled ? "text-primary" : "")} />
<HugeiconsIcon
icon={MagnetIcon}
className={cn(snappingEnabled ? "text-primary" : "")}
/>
}
tooltip="Auto snapping"
onClick={() => toggleSnapping()}
@@ -267,7 +288,13 @@ function ToolbarRightSection({
<ToolbarButton
icon={
<Link className={cn(rippleEditingEnabled ? "text-primary" : "")} />
<HugeiconsIcon
icon={Link04Icon}
className={cn(
rippleEditingEnabled ? "text-primary" : "",
"scale-110",
)}
/>
}
tooltip="Ripple editing"
onClick={() => toggleRippleEditing()}
@@ -283,7 +310,7 @@ function ToolbarRightSection({
type="button"
onClick={() => onZoom({ direction: "out" })}
>
<ZoomOut />
<HugeiconsIcon icon={SearchAddIcon} />
</Button>
<Slider
className="w-24"
@@ -299,7 +326,7 @@ function ToolbarRightSection({
type="button"
onClick={() => onZoom({ direction: "in" })}
>
<ZoomIn />
<HugeiconsIcon icon={SearchMinusIcon} />
</Button>
</div>
</div>
@@ -31,6 +31,7 @@ interface TimelineTrackContentProps {
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
}
export function TimelineTrackContent({
@@ -45,6 +46,7 @@ export function TimelineTrackContent({
onElementMouseDown,
onElementClick,
onTrackMouseDown,
shouldIgnoreClick,
}: TimelineTrackContentProps) {
const editor = useEditor();
const { isElementSelected, clearElementSelection } = useElementSelection();
@@ -66,7 +68,10 @@ export function TimelineTrackContent({
return (
<button
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
onClick={clearElementSelection}
onClick={() => {
if (shouldIgnoreClick?.()) return;
clearElementSelection();
}}
onMouseDown={(event) => {
onTrackMouseDown?.(event);
}}
@@ -1,8 +1,11 @@
import { Button } from "./ui/button";
import { OcGithubIcon } from "@opencut/ui/icons";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import {
GithubIcon,
Link04Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function GitHubContributeSection({
title,
@@ -24,7 +27,7 @@ export function GitHubContributeSection({
rel="noopener noreferrer"
>
<Button className="w-full" size="lg">
<OcGithubIcon className="size-4" />
<HugeiconsIcon icon={GithubIcon} />
Start contributing
</Button>
</Link>
@@ -34,7 +37,7 @@ export function GitHubContributeSection({
rel="noopener noreferrer"
>
<Button variant="outline" className="w-full" size="lg">
<ExternalLink className="size-4" />
<HugeiconsIcon icon={Link04Icon} />
Report issues
</Button>
</Link>
+4 -3
View File
@@ -7,7 +7,8 @@ import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import Image from "next/image";
import { ThemeToggle } from "./theme-toggle";
import { OcGithubIcon, OcMenuIcon } from "@opencut/ui/icons";
import { GithubIcon, Menu02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@/utils/ui";
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
@@ -62,13 +63,13 @@ export function Header() {
className="flex items-center justify-center p-0"
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
<OcMenuIcon size={30} />
<HugeiconsIcon icon={Menu02Icon} size={30} />
</Button>
</div>
<div className="hidden items-center gap-3 md:flex">
<Link href={SOCIAL_LINKS.github}>
<Button className="bg-background text-sm" variant="outline">
<OcGithubIcon className="size-4" />
<HugeiconsIcon icon={GithubIcon} className="size-4" />
31k+
</Button>
</Link>
+3 -2
View File
@@ -1,9 +1,10 @@
"use client";
import { Button } from "./ui/button";
import { Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { cn } from "@/utils/ui";
import { Sun03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
interface ThemeToggleProps {
className?: string;
@@ -28,7 +29,7 @@ export function ThemeToggle({
onToggle?.(e);
}}
>
<Sun className={cn("!size-[1.1rem]", iconClassName)} />
<HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} />
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
</Button>
);
+1 -1
View File
@@ -231,7 +231,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
};
return (
<div className="relative">
<div className="relative flex-1">
<div
ref={ref}
className={cn(
+17
View File
@@ -0,0 +1,17 @@
import { HugeiconsIcon, type HugeiconsIconProps } from "@hugeicons/react";
import { Loading03Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) {
return (
<HugeiconsIcon
icon={Loading03Icon}
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
+26 -5
View File
@@ -1,5 +1,11 @@
import type { TrackType } from "@/types/timeline";
import { Video, TypeIcon, Music, Sticker } from "lucide-react";
import {
Happy01Icon,
MusicNote03Icon,
TextIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcVideoIcon } from "@opencut/ui/icons";
export const TRACK_COLORS: Record<TrackType, { background: string }> = {
video: {
@@ -37,8 +43,23 @@ export const TIMELINE_CONSTANTS = {
} as const;
export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
video: <Video className="text-muted-foreground size-4 shrink-0" />,
text: <TypeIcon className="text-muted-foreground size-4 shrink-0" />,
audio: <Music className="text-muted-foreground size-4 shrink-0" />,
sticker: <Sticker className="text-muted-foreground size-4 shrink-0" />,
video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
text: (
<HugeiconsIcon
icon={TextIcon}
className="text-muted-foreground size-4 shrink-0"
/>
),
audio: (
<HugeiconsIcon
icon={MusicNote03Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
sticker: (
<HugeiconsIcon
icon={Happy01Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
} as const;
@@ -510,13 +510,9 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
event.stopPropagation();
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
const isRightClick = event.button === 2;
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
// right-click
// right-click: don't stop propagation so ContextMenu can open
if (isRightClick) {
const alreadySelected = isElementSelected({
trackId: track.id,
@@ -532,6 +528,12 @@ export function useElementInteraction({
return;
}
// left-click: stop propagation for drag operations
event.stopPropagation();
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
// multi-select: toggle selection
if (isMultiSelect) {
handleSelectionClick({
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
import { useEditor } from "../use-editor";
@@ -98,6 +98,7 @@ export function useSelectionBox({
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
null,
);
const justFinishedSelectingRef = useRef(false);
const handleMouseDown = useCallback(
({ clientX, clientY }: React.MouseEvent) => {
@@ -195,6 +196,12 @@ export function useSelectionBox({
};
const handleMouseUp = () => {
if (selectionBox?.isActive) {
justFinishedSelectingRef.current = true;
requestAnimationFrame(() => {
justFinishedSelectingRef.current = false;
});
}
setSelectionBox(null);
};
@@ -223,9 +230,14 @@ export function useSelectionBox({
};
}, [selectionBox, containerRef]);
const shouldIgnoreClick = useCallback(() => {
return justFinishedSelectingRef.current;
}, []);
return {
selectionBox,
handleMouseDown,
isSelecting: selectionBox?.isActive || false,
shouldIgnoreClick,
};
}
@@ -1,17 +1,18 @@
import {
CaptionsIcon,
ArrowLeftRightIcon,
SparklesIcon,
StickerIcon,
MusicIcon,
VideoIcon,
BlendIcon,
SlidersHorizontalIcon,
type LucideIcon,
TypeIcon,
SettingsIcon,
} from "lucide-react";
import type { ElementType } from "react";
import { create } from "zustand";
import {
ArrowRightDoubleIcon,
ClosedCaptionIcon,
Folder03Icon,
Happy01Icon,
HeadphonesIcon,
MagicWand05Icon,
TextIcon,
Settings01Icon,
SlidersHorizontalIcon,
ColorsIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
export const TAB_KEYS = [
"media",
@@ -28,48 +29,57 @@ export const TAB_KEYS = [
export type Tab = (typeof TAB_KEYS)[number];
const createHugeiconsIcon =
({ icon }: { icon: IconSvgElement }) =>
({ className }: { className?: string }) => (
<HugeiconsIcon icon={icon} className={className} />
);
export const tabs = {
media: {
icon: VideoIcon,
icon: createHugeiconsIcon({ icon: Folder03Icon }),
label: "Media",
},
sounds: {
icon: MusicIcon,
icon: createHugeiconsIcon({ icon: HeadphonesIcon }),
label: "Sounds",
},
text: {
icon: TypeIcon,
icon: createHugeiconsIcon({ icon: TextIcon }),
label: "Text",
},
stickers: {
icon: StickerIcon,
icon: createHugeiconsIcon({ icon: Happy01Icon }),
label: "Stickers",
},
effects: {
icon: SparklesIcon,
icon: createHugeiconsIcon({ icon: MagicWand05Icon }),
label: "Effects",
},
transitions: {
icon: ArrowLeftRightIcon,
icon: createHugeiconsIcon({ icon: ArrowRightDoubleIcon }),
label: "Transitions",
},
captions: {
icon: CaptionsIcon,
icon: createHugeiconsIcon({ icon: ClosedCaptionIcon }),
label: "Captions",
},
filters: {
icon: BlendIcon,
icon: createHugeiconsIcon({ icon: ColorsIcon }),
label: "Filters",
},
adjustment: {
icon: SlidersHorizontalIcon,
icon: createHugeiconsIcon({ icon: SlidersHorizontalIcon }),
label: "Adjustment",
},
settings: {
icon: SettingsIcon,
icon: createHugeiconsIcon({ icon: Settings01Icon }),
label: "Settings",
},
} satisfies Record<Tab, { icon: LucideIcon; label: string }>;
} satisfies Record<
Tab,
{ icon: ElementType<{ className?: string }>; label: string }
>;
type MediaViewMode = "grid" | "list";