From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- .../editor/panels/properties/audio-properties.tsx | 5 + .../components/editor/panels/properties/index.tsx | 68 ++++ .../editor/panels/properties/property-item.tsx | 91 +++++ .../editor/panels/properties/text-properties.tsx | 410 +++++++++++++++++++++ .../editor/panels/properties/video-properties.tsx | 9 + 5 files changed, 583 insertions(+) create mode 100644 apps/web/src/components/editor/panels/properties/audio-properties.tsx create mode 100644 apps/web/src/components/editor/panels/properties/index.tsx create mode 100644 apps/web/src/components/editor/panels/properties/property-item.tsx create mode 100644 apps/web/src/components/editor/panels/properties/text-properties.tsx create mode 100644 apps/web/src/components/editor/panels/properties/video-properties.tsx (limited to 'apps/web/src/components/editor/panels/properties') diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx new file mode 100644 index 00000000..0b83a4b6 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx @@ -0,0 +1,5 @@ +import type { AudioElement } from "@/types/timeline"; + +export function AudioProperties({ _element }: { _element: AudioElement }) { + return
Audio properties
; +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx new file mode 100644 index 00000000..3a552b93 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { AudioProperties } from "./audio-properties"; +import { VideoProperties } from "./video-properties"; +import { TextProperties } from "./text-properties"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Settings05Icon } from "@hugeicons/core-free-icons"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; + +export function PropertiesPanel() { + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + + const elementsWithTracks = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + }); + + return ( + <> + {selectedElements.length > 0 ? ( + + {elementsWithTracks.map(({ track, element }) => { + if (element.type === "text") { + return ( +
+ +
+ ); + } + if (element.type === "audio") { + return ; + } + if (element.type === "video" || element.type === "image") { + return ( +
+ +
+ ); + } + return null; + })} +
+ ) : ( + + )} + + ); +} + +function EmptyView() { + return ( +
+ +
+

It's empty here

+

+ Click an element on the timeline to edit its properties +

+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx new file mode 100644 index 00000000..6d2fe444 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/property-item.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowDownIcon } from "@hugeicons/core-free-icons"; + +interface PropertyItemProps { + direction?: "row" | "column"; + children: React.ReactNode; + className?: string; +} + +export function PropertyItem({ + direction = "row", + children, + className, +}: PropertyItemProps) { + return ( +
+ {children} +
+ ); +} + +export function PropertyItemLabel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +export function PropertyItemValue({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} + +interface PropertyGroupProps { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; + className?: string; + titleClassName?: string; +} + +export function PropertyGroup({ + title, + children, + defaultExpanded = true, + className, + titleClassName, +}: PropertyGroupProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + + + {isExpanded && {children}} + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx new file mode 100644 index 00000000..f8c9bf87 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -0,0 +1,410 @@ +import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +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"; +import { useState, useRef } from "react"; +import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; +import { + TEXT_PROPERTIES_TABS, + isTextPropertiesTab, + useTextPropertiesStore, +} from "@/stores/text-properties-store"; +import { + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "./property-item"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { cn } from "@/utils/ui"; +import { capitalizeFirstLetter, uppercase } from "@/utils/string"; +import { clamp } from "@/utils/math"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { LayoutGridIcon } from "@hugeicons/core-free-icons"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_COLOR } from "@/constants/project-constants"; + +export function TextProperties({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const { activeTab, setActiveTab } = useTextPropertiesStore(); + const containerRef = useRef(null); + const [fontSizeInput, setFontSizeInput] = useState( + element.fontSize.toString(), + ); + const [opacityInput, setOpacityInput] = useState( + Math.round(element.opacity * 100).toString(), + ); + + const lastSelectedColor = useRef(DEFAULT_COLOR); + + const handleFontSizeChange = ({ value }: { value: string }) => { + setFontSizeInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + } + }; + + const handleFontSizeBlur = () => { + const parsed = parseInt(fontSizeInput, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + setFontSizeInput(fontSize.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + }; + + const handleOpacityChange = ({ value }: { value: string }) => { + setOpacityInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + } + }; + + const handleOpacityBlur = () => { + const parsed = parseInt(opacityInput, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + setOpacityInput(opacityPercent.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + }; + + const handleColorChange = ({ color }: { color: string }) => { + if (color !== "transparent") { + lastSelectedColor.current = color; + } + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }); + }; + + const handleTransparentToggle = ({ + isTransparent, + }: { + isTransparent: boolean; + }) => { + const newColor = isTransparent ? "transparent" : lastSelectedColor.current; + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: newColor }, + }); + }; + + return ( + { + if (isTextPropertiesTab(v)) setActiveTab(v); + }} + ref={containerRef} + tabs={TEXT_PROPERTIES_TABS.map((t) => ({ + value: t.value, + label: t.label, + content: + t.value === "transform" ? ( +
+ ) : ( +
+