summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor-header.tsx8
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx4
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx107
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx105
-rw-r--r--apps/web/src/components/editor/properties-panel/index.tsx64
-rw-r--r--apps/web/src/components/editor/properties-panel/text-properties.tsx14
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx9
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx14
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx1
-rw-r--r--apps/web/src/components/header.tsx24
-rw-r--r--apps/web/src/components/keyboard-shortcuts-help.tsx17
-rw-r--r--apps/web/src/components/landing/handlebars.tsx173
-rw-r--r--apps/web/src/components/ui/dialog.tsx2
13 files changed, 313 insertions, 229 deletions
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx
index 6e9af186..837bd97a 100644
--- a/apps/web/src/components/editor-header.tsx
+++ b/apps/web/src/components/editor-header.tsx
@@ -10,6 +10,7 @@ import { useProjectStore } from "@/stores/project-store";
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
import { useState, useRef } from "react";
import { Input } from "@/components/ui/input";
+import { toast } from "sonner";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
@@ -22,6 +23,7 @@ export function EditorHeader() {
// TODO: Implement export functionality
// NOTE: This is already being worked on
console.log("Export project");
+ window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank");
};
const handleNameClick = () => {
@@ -55,11 +57,11 @@ export function EditorHeader() {
>
<ChevronLeft className="h-4 w-4" />
</Link>
- <div className="w-[14rem] h-9 flex items-center">
+ <div className="w-40 xl:w-60 h-9 flex items-center">
{isEditing ? (
<Input
ref={inputRef}
- className="text-sm font-medium px-2 py-1 h-9 truncate"
+ className="text-sm font-medium w-full px-2 py-1 h-9 truncate"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onBlur={handleNameSave}
@@ -78,7 +80,7 @@ export function EditorHeader() {
onClick={handleNameClick}
onKeyDown={(e) => e.key === "Enter" && handleNameClick()}
>
- <div className="truncate text-ellipsis overflow-clip w-40">
+ <div className="truncate text-ellipsis overflow-clip max-w-40 xl:max-w-60">
{activeProject?.name}
</div>
</span>
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx
index 2c743618..359612f8 100644
--- a/apps/web/src/components/editor/media-panel/index.tsx
+++ b/apps/web/src/components/editor/media-panel/index.tsx
@@ -46,9 +46,9 @@ export function MediaPanel() {
};
return (
- <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
+ <div className="h-full flex flex-col bg-panel rounded-sm">
<TabBar />
- <div className="flex-1">{viewMap[activeTab]}</div>
+ <div className="flex-1 overflow-y-auto">{viewMap[activeTab]}</div>
</div>
);
}
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
index 66d0d00d..f218dc14 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -25,6 +25,7 @@ import {
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
+import { ScrollArea } from "@/components/ui/scroll-area";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
@@ -204,7 +205,7 @@ export function MediaView() {
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps}
>
- <div className="p-3 pb-2">
+ <div className="p-3 pb-2 bg-panel">
{/* Search and filter controls */}
<div className="flex gap-2">
<Select value={mediaFilter} onValueChange={setMediaFilter}>
@@ -241,57 +242,59 @@ export function MediaView() {
</div>
</div>
- <div className="flex-1 overflow-y-auto p-3 pt-0">
- {isDragOver || filteredMediaItems.length === 0 ? (
- <MediaDragOverlay
- isVisible={true}
- isProcessing={isProcessing}
- progress={progress}
- onClick={handleFileSelect}
- isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
- />
- ) : (
- <div
- className="grid gap-2"
- style={{
- gridTemplateColumns: "repeat(auto-fill, 160px)",
- }}
- >
- {/* Render each media item as a draggable button */}
- {filteredMediaItems.map((item) => (
- <ContextMenu key={item.id}>
- <ContextMenuTrigger>
- <DraggableMediaItem
- name={item.name}
- preview={renderPreview(item)}
- dragData={{
- id: item.id,
- type: item.type,
- name: item.name,
- }}
- showPlusOnDrag={false}
- onAddToTimeline={(currentTime) =>
- useTimelineStore
- .getState()
- .addMediaAtTime(item, currentTime)
- }
- rounded={false}
- />
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem>Export clips</ContextMenuItem>
- <ContextMenuItem
- variant="destructive"
- onClick={(e) => handleRemove(e, item.id)}
- >
- Delete
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </div>
- )}
- </div>
+ <ScrollArea className="h-full">
+ <div className="flex-1 p-3 pt-0">
+ {isDragOver || filteredMediaItems.length === 0 ? (
+ <MediaDragOverlay
+ isVisible={true}
+ isProcessing={isProcessing}
+ progress={progress}
+ onClick={handleFileSelect}
+ isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
+ />
+ ) : (
+ <div
+ className="grid gap-2"
+ style={{
+ gridTemplateColumns: "repeat(auto-fill, 160px)",
+ }}
+ >
+ {/* Render each media item as a draggable button */}
+ {filteredMediaItems.map((item) => (
+ <ContextMenu key={item.id}>
+ <ContextMenuTrigger>
+ <DraggableMediaItem
+ name={item.name}
+ preview={renderPreview(item)}
+ dragData={{
+ id: item.id,
+ type: item.type,
+ name: item.name,
+ }}
+ showPlusOnDrag={false}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore
+ .getState()
+ .addMediaAtTime(item, currentTime)
+ }
+ rounded={false}
+ />
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem>Export clips</ContextMenuItem>
+ <ContextMenuItem
+ variant="destructive"
+ onClick={(e) => handleRemove(e, item.id)}
+ >
+ Delete
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
</div>
</>
);
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index f09e8462..639858d0 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -23,6 +23,7 @@ import { formatTimeCode } from "@/lib/time";
import { FONT_CLASS_MAP } from "@/lib/font-config";
import { BackgroundSettings } from "../background-settings";
import { useProjectStore } from "@/stores/project-store";
+import { TextElementDragState } from "@/types/editor";
interface ActiveElement {
element: TimelineElement;
@@ -31,7 +32,7 @@ interface ActiveElement {
}
export function PreviewPanel() {
- const { tracks, getTotalDuration } = useTimelineStore();
+ const { tracks, getTotalDuration, updateTextElement } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore();
const { canvasSize } = useEditorStore();
@@ -43,6 +44,19 @@ export function PreviewPanel() {
});
const [isExpanded, setIsExpanded] = useState(false);
const { activeProject } = useProjectStore();
+ const [dragState, setDragState] = useState<TextElementDragState>({
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startX: 0,
+ startY: 0,
+ initialElementX: 0,
+ initialElementY: 0,
+ currentX: 0,
+ currentY: 0,
+ elementWidth: 0,
+ elementHeight: 0,
+ });
useEffect(() => {
const updatePreviewSize = () => {
@@ -129,6 +143,86 @@ export function PreviewPanel() {
};
}, [isExpanded]);
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!dragState.isDragging) return;
+
+ const deltaX = e.clientX - dragState.startX;
+ const deltaY = e.clientY - dragState.startY;
+
+ const scaleRatio = previewDimensions.width / canvasSize.width;
+ const newX = dragState.initialElementX + deltaX / scaleRatio;
+ const newY = dragState.initialElementY + deltaY / scaleRatio;
+
+ const halfWidth = dragState.elementWidth / scaleRatio / 2;
+ const halfHeight = dragState.elementHeight / scaleRatio / 2;
+
+ const constrainedX = Math.max(
+ -canvasSize.width / 2 + halfWidth,
+ Math.min(canvasSize.width / 2 - halfWidth, newX)
+ );
+ const constrainedY = Math.max(
+ -canvasSize.height / 2 + halfHeight,
+ Math.min(canvasSize.height / 2 - halfHeight, newY)
+ );
+
+ setDragState((prev) => ({
+ ...prev,
+ currentX: constrainedX,
+ currentY: constrainedY,
+ }));
+ };
+
+ const handleMouseUp = () => {
+ if (dragState.isDragging && dragState.trackId && dragState.elementId) {
+ updateTextElement(dragState.trackId, dragState.elementId, {
+ x: dragState.currentX,
+ y: dragState.currentY,
+ });
+ }
+ setDragState((prev) => ({ ...prev, isDragging: false }));
+ };
+
+ if (dragState.isDragging) {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ document.body.style.cursor = "grabbing";
+ document.body.style.userSelect = "none";
+ }
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+ }, [dragState, previewDimensions, canvasSize, updateTextElement]);
+
+ const handleTextMouseDown = (
+ e: React.MouseEvent<HTMLDivElement>,
+ element: any,
+ trackId: string
+ ) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const rect = e.currentTarget.getBoundingClientRect();
+
+ setDragState({
+ isDragging: true,
+ elementId: element.id,
+ trackId: trackId,
+ startX: e.clientX,
+ startY: e.clientY,
+ initialElementX: element.x,
+ initialElementY: element.y,
+ currentX: element.x,
+ currentY: element.y,
+ elementWidth: rect.width,
+ elementHeight: rect.height,
+ });
+ };
+
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
@@ -256,10 +350,13 @@ export function PreviewPanel() {
return (
<div
key={element.id}
- className="absolute flex items-center justify-center"
+ className="absolute flex items-center justify-center cursor-grab"
+ onMouseDown={(e) =>
+ handleTextMouseDown(e, element, elementData.track.id)
+ }
style={{
- left: `${50 + (element.x / canvasSize.width) * 100}%`,
- top: `${50 + (element.y / canvasSize.height) * 100}%`,
+ left: `${50 + ((dragState.isDragging && dragState.elementId === element.id ? dragState.currentX : element.x) / canvasSize.width) * 100}%`,
+ top: `${50 + ((dragState.isDragging && dragState.elementId === element.id ? dragState.currentY : element.y) / canvasSize.height) * 100}%`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
opacity: element.opacity,
zIndex: 100 + index, // Text elements on top
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx
index 4fc5d89d..720c4a26 100644
--- a/apps/web/src/components/editor/properties-panel/index.tsx
+++ b/apps/web/src/components/editor/properties-panel/index.tsx
@@ -1,14 +1,12 @@
"use client";
-import { useProjectStore } from "@/stores/project-store";
+import { FPS_PRESETS } from "@/constants/timeline-constants";
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
+import { useMediaStore } from "@/stores/media-store";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineStore } from "@/stores/timeline-store";
import { Label } from "../../ui/label";
import { ScrollArea } from "../../ui/scroll-area";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { AudioProperties } from "./audio-properties";
-import { MediaProperties } from "./media-properties";
-import { TextProperties } from "./text-properties";
import {
Select,
SelectContent,
@@ -16,7 +14,14 @@ import {
SelectTrigger,
SelectValue,
} from "../../ui/select";
-import { FPS_PRESETS } from "@/constants/timeline-constants";
+import { AudioProperties } from "./audio-properties";
+import { MediaProperties } from "./media-properties";
+import {
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "./property-item";
+import { TextProperties } from "./text-properties";
export function PropertiesPanel() {
const { activeProject, updateProjectFps } = useProjectStore();
@@ -35,13 +40,31 @@ export function PropertiesPanel() {
<div className="space-y-4 p-5">
{/* Media Properties */}
<div className="flex flex-col gap-3">
- <PropertyItem label="Name:" value={activeProject?.name || ""} />
- <PropertyItem label="Aspect ratio:" value={getDisplayName()} />
- <PropertyItem
- label="Resolution:"
- value={`${canvasSize.width} × ${canvasSize.height}`}
- />
- <div className="flex justify-between items-center">
+ <PropertyItem direction="column">
+ <PropertyItemLabel className="text-xs text-muted-foreground">
+ Name:
+ </PropertyItemLabel>
+ <PropertyItemValue className="text-xs truncate">
+ {activeProject?.name || ""}
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel className="text-xs text-muted-foreground">
+ Aspect ratio:
+ </PropertyItemLabel>
+ <PropertyItemValue className="text-xs truncate">
+ {getDisplayName()}
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel className="text-xs text-muted-foreground">
+ Resolution:
+ </PropertyItemLabel>
+ <PropertyItemValue className="text-xs truncate">
+ {`${canvasSize.width} × ${canvasSize.height}`}
+ </PropertyItemValue>
+ </PropertyItem>
+ <div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Frame rate:</Label>
<Select
value={(activeProject?.fps || 30).toString()}
@@ -83,7 +106,7 @@ export function PropertiesPanel() {
);
if (mediaItem?.type === "audio") {
- return <AudioProperties element={element} />;
+ return <AudioProperties key={elementId} element={element} />;
}
return (
@@ -98,14 +121,3 @@ export function PropertiesPanel() {
</ScrollArea>
);
}
-
-function PropertyItem({ label, value }: { label: string; value: string }) {
- return (
- <div className="flex justify-between">
- <Label className="text-xs text-muted-foreground">{label}</Label>
- <span className="text-xs text-right truncate w-40" title={value}>
- {value}
- </span>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx
index c55c3dae..e147ea65 100644
--- a/apps/web/src/components/editor/properties-panel/text-properties.tsx
+++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx
@@ -71,6 +71,20 @@ export function TextProperties({
</div>
</PropertyItemValue>
</PropertyItem>
+ <PropertyItem direction="row">
+ <PropertyItemLabel>Color</PropertyItemLabel>
+ <PropertyItemValue>
+ <Input
+ type="color"
+ value={element.color || "#ffffff"}
+ onChange={(e) => {
+ const color = e.target.value;
+ updateTextElement(trackId, element.id, { color });
+ }}
+ className="w-full cursor-pointer rounded-full"
+ />
+ </PropertyItemValue>
+ </PropertyItem>
</div>
);
}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index fd2b2a11..bcaece89 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -744,13 +744,16 @@ export function Timeline() {
/>
</div>
</ContextMenuTrigger>
- <ContextMenuContent>
+ <ContextMenuContent className="z-[200]">
<ContextMenuItem
- onClick={() => toggleTrackMute(track.id)}
+ onClick={(e) => {
+ e.stopPropagation();
+ toggleTrackMute(track.id);
+ }}
>
{track.muted ? "Unmute Track" : "Mute Track"}
</ContextMenuItem>
- <ContextMenuItem>
+ <ContextMenuItem onClick={(e) => e.stopPropagation()}>
Track settings (soon)
</ContextMenuItem>
</ContextMenuContent>
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index 8aadde82..c66e9fcb 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -102,7 +102,8 @@ export function TimelineElement({
// Element should always be positioned at startTime - trimStart only affects content, not position
const elementLeft = elementStartTime * 50 * zoomLevel;
- const handleElementSplitContext = () => {
+ const handleElementSplitContext = (e: React.MouseEvent) => {
+ e.stopPropagation();
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
@@ -118,7 +119,8 @@ export function TimelineElement({
}
};
- const handleElementDuplicateContext = () => {
+ const handleElementDuplicateContext = (e: React.MouseEvent) => {
+ e.stopPropagation();
const { id, ...elementWithoutId } = element;
addElementToTrack(track.id, {
...elementWithoutId,
@@ -130,7 +132,8 @@ export function TimelineElement({
});
};
- const handleElementDeleteContext = () => {
+ const handleElementDeleteContext = (e: React.MouseEvent) => {
+ e.stopPropagation();
if (rippleEditingEnabled) {
removeElementFromTrackWithRipple(track.id, element.id);
} else {
@@ -138,7 +141,8 @@ export function TimelineElement({
}
};
- const handleReplaceClip = () => {
+ const handleReplaceClip = (e: React.MouseEvent) => {
+ e.stopPropagation();
if (element.type !== "media") {
toast.error("Replace is only available for media clips");
return;
@@ -354,7 +358,7 @@ export function TimelineElement({
</div>
</div>
</ContextMenuTrigger>
- <ContextMenuContent>
+ <ContextMenuContent className="z-[200]">
<ContextMenuItem onClick={handleElementSplitContext}>
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
index 509ce9d7..d0aea7fa 100644
--- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
@@ -72,6 +72,7 @@ export function TimelinePlayhead({
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
+ zIndex: 100,
}}
onMouseDown={handlePlayheadMouseDown}
>
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 2e6de534..f5f847be 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -15,17 +15,19 @@ export function Header() {
);
const rightContent = (
- <nav className="flex items-center gap-3">
- <Link href="/blog">
- <Button variant="text" className="text-sm p-0">
- Blog
- </Button>
- </Link>
- <Link href="/contributors">
- <Button variant="text" className="text-sm p-0">
- Contributors
- </Button>
- </Link>
+ <nav className="flex items-center gap-1">
+ <div className="flex items-center gap-4">
+ <Link href="/blog">
+ <Button variant="text" className="text-sm p-0">
+ Blog
+ </Button>
+ </Link>
+ <Link href="/contributors">
+ <Button variant="text" className="text-sm p-0">
+ Contributors
+ </Button>
+ </Link>
+ </div>
<Link href="/projects">
<Button size="sm" className="text-sm ml-4">
Projects
diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx
index 65a73b90..96d4705f 100644
--- a/apps/web/src/components/keyboard-shortcuts-help.tsx
+++ b/apps/web/src/components/keyboard-shortcuts-help.tsx
@@ -91,8 +91,8 @@ const EditableShortcutKey = ({
return (
<Button
- variant='text'
- size='sm'
+ variant="text"
+ size="sm"
className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${
isRecording
? "border-primary bg-primary/10"
@@ -141,11 +141,11 @@ export const KeyboardShortcutsHelp = () => {
// Auto-save the new keybinding
const conflict = validateKeybinding(
keyString,
- recordingShortcut.action,
+ recordingShortcut.action
);
if (conflict) {
toast.error(
- `Key "${keyString}" is already bound to "${conflict.existingAction}"`,
+ `Key "${keyString}" is already bound to "${conflict.existingAction}"`
);
setRecordingShortcut(null);
return;
@@ -199,7 +199,7 @@ export const KeyboardShortcutsHelp = () => {
Shortcuts
</Button>
</DialogTrigger>
- <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
+ <DialogContent className="max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5" />
@@ -235,7 +235,12 @@ export const KeyboardShortcutsHelp = () => {
))}
</div>
<DialogFooter>
- <Button size="sm" className="mt-4" onClick={resetToDefaults}>
+ <Button
+ size="sm"
+ className="mt-4"
+ variant="destructive"
+ onClick={resetToDefaults}
+ >
Reset to Default
</Button>
</DialogFooter>
diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx
index 94764dbe..26cf9254 100644
--- a/apps/web/src/components/landing/handlebars.tsx
+++ b/apps/web/src/components/landing/handlebars.tsx
@@ -1,163 +1,104 @@
"use client";
-import React, { useState, useRef, useEffect } from "react";
-import { motion, useMotionValue, useTransform, PanInfo } from "motion/react";
+import {
+ motion,
+ useMotionTemplate,
+ useMotionValue,
+ useMotionValueEvent,
+ useTransform,
+} from "motion/react";
+import { type PropsWithChildren, useEffect, useRef, useState } from "react";
-interface HandlebarsProps {
- children: React.ReactNode;
- minWidth?: number;
- maxWidth?: number;
- onRangeChange?: (left: number, right: number) => void;
-}
-
-export function Handlebars({
- children,
- minWidth = 50,
- maxWidth = 400,
- onRangeChange,
-}: HandlebarsProps) {
- const [leftHandle, setLeftHandle] = useState(0);
- const [rightHandle, setRightHandle] = useState(maxWidth);
- const [contentWidth, setContentWidth] = useState(maxWidth);
-
- const leftHandleX = useMotionValue(0);
- const rightHandleX = useMotionValue(maxWidth);
-
- const visibleWidth = useTransform(
- [leftHandleX, rightHandleX],
- (values: number[]) => values[1] - values[0]
- );
-
- const contentLeft = useTransform(leftHandleX, (left: number) => -left);
+type HandlebarsProps = PropsWithChildren;
+export function Handlebars({ children }: HandlebarsProps) {
const containerRef = useRef<HTMLDivElement>(null);
- const measureRef = useRef<HTMLDivElement>(null);
-
- useEffect(() => {
- if (!measureRef.current) return;
-
- const measureContent = () => {
- if (measureRef.current) {
- const width = measureRef.current.scrollWidth;
- const paddedWidth = width + 32;
- setContentWidth(paddedWidth);
- setRightHandle(paddedWidth);
- rightHandleX.set(paddedWidth);
- }
- };
- measureContent();
- const timer = setTimeout(measureContent, 50);
+ const [width, setWidth] = useState(0);
- return () => clearTimeout(timer);
- }, [children, rightHandleX]);
+ const [leftHandle, setLeftHandle] = useState(0);
+ const [rightHandle, setRightHandle] = useState(width);
- useEffect(() => {
- leftHandleX.set(leftHandle);
- }, [leftHandle, leftHandleX]);
+ const leftHandleX = useMotionValue(0);
+ const rightHandleX = useMotionValue(width);
useEffect(() => {
- rightHandleX.set(rightHandle);
- }, [rightHandle, rightHandleX]);
+ const el = containerRef.current;
+ if (!el) return;
- useEffect(() => {
- onRangeChange?.(leftHandle, rightHandle);
- }, [leftHandle, rightHandle, onRangeChange]);
+ const observer = new ResizeObserver(() => {
+ setWidth(el.offsetWidth);
+ setRightHandle(el.offsetWidth);
+ rightHandleX.set(el.offsetWidth);
+ });
- const handleLeftDrag = (event: any, info: PanInfo) => {
- const newLeft = Math.max(
- 0,
- Math.min(leftHandle + info.offset.x, rightHandle - minWidth)
- );
- setLeftHandle(newLeft);
- };
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [rightHandleX.set]);
- const handleRightDrag = (event: any, info: PanInfo) => {
- const newRight = Math.max(
- leftHandle + minWidth,
- Math.min(contentWidth, rightHandle + info.offset.x)
- );
- setRightHandle(newRight);
- };
+ useMotionValueEvent(leftHandleX, "change", () => {
+ setLeftHandle(leftHandleX.get());
+ });
+ useMotionValueEvent(rightHandleX, "change", () => {
+ setRightHandle(rightHandleX.get());
+ });
+ const leftGradient = useTransform(leftHandleX, [0, width - 10], [0, 100]);
+ const rightGradient = useTransform(rightHandleX, [0, width + 10], [0, 100]);
return (
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
- <div
- ref={measureRef}
- className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]"
- >
- {children}
- </div>
-
- <div
- ref={containerRef}
- className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2"
- style={{ width: contentWidth }}
- >
- <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between">
+ <div ref={containerRef} className="relative -rotate-[2.76deg] mt-2">
+ <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between z-[1]">
<motion.div
- className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
+ className="absolute z-10 left-0 h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none"
style={{
- position: "absolute",
x: leftHandleX,
- left: 0,
- zIndex: 10,
}}
drag="x"
- dragConstraints={{ left: 0, right: rightHandle - minWidth }}
+ dragConstraints={{ left: 0, right: rightHandle - 60 }}
dragElastic={0}
dragMomentum={false}
- onDrag={handleLeftDrag}
- whileHover={{ scale: 1.05 }}
- whileDrag={{ scale: 1.1 }}
+ whileHover={{ scale: 1.05, cursor: "grab" }}
+ whileDrag={{ scale: 1.1, cursor: "grabbing" }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
- <div className="w-2 h-8 rounded-full bg-yellow-500" />
+ <div className="w-2 h-8 rounded-full bg-yellow-500"></div>
</motion.div>
<motion.div
- className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
+ className="absolute z-10 -left-[30px] h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none"
style={{
- position: "absolute",
x: rightHandleX,
- left: -30,
- zIndex: 10,
}}
drag="x"
dragConstraints={{
- left: leftHandle + minWidth,
- right: contentWidth,
+ left: leftHandle + 60,
+ right: width,
}}
dragElastic={0}
dragMomentum={false}
- onDrag={handleRightDrag}
- whileHover={{ scale: 1.05 }}
- whileDrag={{ scale: 1.1 }}
+ whileHover={{ scale: 1.05, cursor: "grab" }}
+ whileDrag={{ scale: 1.1, cursor: "grabbing" }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
- <div className="w-2 h-8 rounded-full bg-yellow-500" />
+ <div className="w-2 h-8 rounded-full bg-yellow-500"></div>
</motion.div>
</div>
- <motion.div
- className="relative overflow-hidden rounded-2xl"
+ <motion.span
+ className="inline-flex items-center justify-center w-full h-full px-9 relative rounded-2xl will-change-auto"
style={{
- width: visibleWidth,
- x: leftHandleX,
- height: "100%",
+ mask: useMotionTemplate`linear-gradient(90deg,
+ rgba(255, 255, 255, 0) 0%,
+ rgba(255, 255, 255, 0) ${leftGradient}%,
+ rgba(0, 0, 0) ${leftGradient}%,
+ rgba(0, 0, 0) ${rightGradient}%,
+ rgba(255, 255, 255, 0) ${rightGradient}%,
+ rgba(255, 255, 255, 0) 100%)`,
}}
>
- <motion.div
- className="w-full h-full flex items-center justify-center px-4"
- style={{
- x: contentLeft,
- width: contentWidth,
- whiteSpace: "nowrap",
- }}
- >
- {children}
- </motion.div>
- </motion.div>
+ {children}
+ </motion.span>
</div>
</div>
);
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index e8decbd7..35843a5f 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
}}
{...props}
>
- <ScrollArea className="max-h-[85vh]">
+ <ScrollArea className="max-h-[75vh]">
<div className="p-6 space-y-4">{children}</div>
</ScrollArea>
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">