mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
lots of stuff
This commit is contained in:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.git
|
||||
apps/web/.next/
|
||||
apps/web/public/
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
Vendored
+6
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +1,3 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export async function GET() {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -41,8 +41,7 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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,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,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,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands";
|
||||
import type { Command } from "@/lib/commands";
|
||||
|
||||
export class CommandManager {
|
||||
private history: Command[] = [];
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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,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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,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,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,4 +1,4 @@
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export * from "./track-utils";
|
||||
export * from "./element-utils";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user