summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/media-panel
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-09-03 00:02:07 +0200
committerMaze Winther <mazewinther@gmail.com>2025-09-03 00:02:07 +0200
commit60debe2ce52a73dc554a0daa0a127fc836ca123a (patch)
tree9c334f5de4211d0bb7893929ea63c86280679705 /apps/web/src/components/editor/media-panel
parent37d40148087fc7c3497fba31e82bc7c7496bde8d (diff)
parent167e2b83dc072f5d80ab0bf0d3f786a993248c70 (diff)
downloadopencut-60debe2ce52a73dc554a0daa0a127fc836ca123a.tar.gz
Merge branch 'staging'
Diffstat (limited to 'apps/web/src/components/editor/media-panel')
-rw-r--r--apps/web/src/components/editor/media-panel/tabbar.tsx52
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx2
-rw-r--r--apps/web/src/components/editor/media-panel/views/settings.tsx47
-rw-r--r--apps/web/src/components/editor/media-panel/views/sounds.tsx70
-rw-r--r--apps/web/src/components/editor/media-panel/views/stickers.tsx141
5 files changed, 198 insertions, 114 deletions
diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx
index 56437508..e2bfdff7 100644
--- a/apps/web/src/components/editor/media-panel/tabbar.tsx
+++ b/apps/web/src/components/editor/media-panel/tabbar.tsx
@@ -7,13 +7,45 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { useEffect, useRef, useState } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const [showTopFade, setShowTopFade] = useState(false);
+ const [showBottomFade, setShowBottomFade] = useState(false);
+
+ const checkScrollPosition = () => {
+ 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;
+ if (!element) return;
+
+ checkScrollPosition();
+ element.addEventListener("scroll", checkScrollPosition);
+
+ const resizeObserver = new ResizeObserver(checkScrollPosition);
+ resizeObserver.observe(element);
+
+ return () => {
+ element.removeEventListener("scroll", checkScrollPosition);
+ resizeObserver.disconnect();
+ };
+ }, []);
return (
- <div className="flex">
- <div className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4">
+ <div className="flex relative">
+ <div
+ ref={scrollRef}
+ className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4"
+ >
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
@@ -46,6 +78,22 @@ export function TabBar() {
);
})}
</div>
+
+ <FadeOverlay direction="top" show={showTopFade} />
+ <FadeOverlay direction="bottom" show={showBottomFade} />
</div>
);
}
+
+function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) {
+ return (
+ <div
+ className={cn(
+ "absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200",
+ direction === "top" && show
+ ? "top-0 bg-gradient-to-b from-panel to-transparent"
+ : "bottom-0 bg-gradient-to-t from-panel to-transparent"
+ )}
+ />
+ );
+} \ No newline at end of file
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx
index 59403a8d..fbe5b418 100644
--- a/apps/web/src/components/editor/media-panel/views/captions.tsx
+++ b/apps/web/src/components/editor/media-panel/views/captions.tsx
@@ -188,7 +188,7 @@ export function Captions() {
};
return (
- <BaseView ref={containerRef} className="flex flex-col justify-between">
+ <BaseView ref={containerRef} className="flex flex-col justify-between h-full">
<PropertyGroup title="Language">
<LanguageSelect
selectedCountry={selectedCountry}
diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx
index df1ec54f..be54a9c4 100644
--- a/apps/web/src/components/editor/media-panel/views/settings.tsx
+++ b/apps/web/src/components/editor/media-panel/views/settings.tsx
@@ -16,15 +16,18 @@ import {
} from "../../properties-panel/property-item";
import { FPS_PRESETS } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
+import type { BlurIntensity } from "@/types/project";
import { useEditorStore } from "@/stores/editor-store";
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import Image from "next/image";
import { cn } from "@/lib/utils";
import { colors } from "@/data/colors/solid";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
-import { PipetteIcon } from "lucide-react";
+import { PipetteIcon, PlusIcon } from "lucide-react";
import { useMemo, memo, useCallback } from "react";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
export function SettingsView() {
return <ProjectSettingsTabs />;
@@ -38,14 +41,40 @@ function ProjectSettingsTabs() {
{
value: "project-info",
label: "Project info",
- content: <ProjectInfoView />,
+ content: (
+ <div className="p-5">
+ <ProjectInfoView />
+ </div>
+ ),
},
{
value: "background",
label: "Background",
- content: <BackgroundView />,
+ content: (
+ <div className="flex flex-col justify-between h-full">
+ <div className="flex-1 p-5">
+ <BackgroundView />
+ </div>
+ <div className="flex flex-col sticky -bottom-0 bg-panel/85 backdrop-blur-lg">
+ <Separator />
+ <Button className="w-fit h-auto p-5 py-4 !bg-transparent shadow-none text-muted-foreground hover:text-foreground/85 text-xs">
+ Custom background
+ <PlusIcon />
+ </Button>
+ </div>
+
+ {/* Another UI, looks so beautiful i don't wanna remove it */}
+ {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
+ <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
+ <span className="text-sm">Custom</span>
+ <PlusIcon className="" />
+ </Button>
+ </div> */}
+ </div>
+ ),
},
]}
+ className="flex flex-col justify-between h-full p-0"
/>
);
}
@@ -177,9 +206,9 @@ const BackgroundPreviews = memo(
}) => {
return useMemo(
() =>
- backgrounds.map((bg) => (
+ backgrounds.map((bg, index) => (
<div
- key={bg}
+ key={`${index}-${bg}`}
className={cn(
"w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary",
isColorBackground &&
@@ -215,7 +244,7 @@ BackgroundPreviews.displayName = "BackgroundPreviews";
function BackgroundView() {
const { activeProject, updateBackgroundType } = useProjectStore();
- const blurLevels = useMemo(
+ const blurLevels = useMemo<Array<{ label: string; value: BlurIntensity }>>(
() => [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
@@ -225,7 +254,7 @@ function BackgroundView() {
);
const handleBlurSelect = useCallback(
- async (blurIntensity: number) => {
+ async (blurIntensity: BlurIntensity) => {
await updateBackgroundType("blur", { blurIntensity });
},
[updateBackgroundType]
@@ -257,7 +286,7 @@ function BackgroundView() {
);
return (
- <div className="flex flex-col gap-4">
+ <div className="flex flex-col gap-4 h-full">
<PropertyGroup title="Blur" defaultExpanded={false}>
<div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
</PropertyGroup>
@@ -277,7 +306,7 @@ function BackgroundView() {
</div>
</PropertyGroup>
- <PropertyGroup title="Pattern Craft" defaultExpanded={false}>
+ <PropertyGroup title="Pattern craft" defaultExpanded={false}>
<div className="grid grid-cols-4 gap-2 w-full">
<BackgroundPreviews
backgrounds={patternCraftGradients}
diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx
index 303eb858..700eaea7 100644
--- a/apps/web/src/components/editor/media-panel/views/sounds.tsx
+++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx
@@ -82,6 +82,14 @@ function SoundEffectsView() {
toggleSavedSound,
showCommercialOnly,
toggleCommercialFilter,
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
} = useSoundsStore();
const {
results: searchResults,
@@ -106,6 +114,55 @@ function SoundEffectsView() {
useEffect(() => {
loadSavedSounds();
+ if (!hasLoaded) {
+ let ignore = false;
+
+ const fetchTopSounds = async () => {
+ try {
+ if (!ignore) {
+ setLoading(true);
+ setError(null);
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads"
+ );
+
+ if (!ignore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects(data.results);
+ setHasLoaded(true);
+
+ setCurrentPage(1);
+ setHasNextPage(!!data.next);
+ setTotalCount(data.count);
+ }
+ } catch (error) {
+ if (!ignore) {
+ console.error("Failed to fetch top sounds:", error);
+ setError(
+ error instanceof Error ? error.message : "Failed to load sounds"
+ );
+ }
+ } finally {
+ if (!ignore) {
+ setLoading(false);
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(fetchTopSounds, 100);
+
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }
+
if (scrollAreaRef.current && scrollPosition > 0) {
const timeoutId = setTimeout(() => {
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
@@ -113,14 +170,23 @@ function SoundEffectsView() {
return () => clearTimeout(timeoutId);
}
- }, []);
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget;
setScrollPosition(scrollTop);
handleScroll(event);
};
-
+
const displayedSounds = useMemo(() => {
const sounds = searchQuery ? searchResults : topSoundEffects;
return sounds;
diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx
index 41afe285..f67450cc 100644
--- a/apps/web/src/components/editor/media-panel/views/stickers.tsx
+++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx
@@ -20,8 +20,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
-import { Separator } from "@/components/ui/separator";
+import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import {
Tooltip,
TooltipContent,
@@ -48,63 +47,41 @@ export function StickersView() {
const { selectedCategory, setSelectedCategory } = useStickersStore();
return (
- <div className="h-full flex flex-col">
- <Tabs
- value={selectedCategory}
- onValueChange={(v) => {
- if (["all", "general", "brands", "emoji"].includes(v)) {
- setSelectedCategory(v as StickerCategory);
- }
- }}
- className="flex flex-col h-full"
- >
- <div className="px-3 pt-4 pb-0">
- <TabsList>
- <TabsTrigger value="all" className="gap-1">
- <Grid3X3 className="h-3 w-3" />
- All
- </TabsTrigger>
- <TabsTrigger value="general" className="gap-1">
- <Sparkles className="h-3 w-3" />
- Icons
- </TabsTrigger>
- <TabsTrigger value="brands" className="gap-1">
- <Hash className="h-3 w-3" />
- Brands
- </TabsTrigger>
- <TabsTrigger value="emoji" className="gap-1">
- <Smile className="h-3 w-3" />
- Emoji
- </TabsTrigger>
- </TabsList>
- </div>
- <Separator className="my-4" />
- <TabsContent
- value="all"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="all" />
- </TabsContent>
- <TabsContent
- value="general"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="general" />
- </TabsContent>
- <TabsContent
- value="brands"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="brands" />
- </TabsContent>
- <TabsContent
- value="emoji"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="emoji" />
- </TabsContent>
- </Tabs>
- </div>
+ <BaseView
+ value={selectedCategory}
+ onValueChange={(v) => {
+ if (["all", "general", "brands", "emoji"].includes(v)) {
+ setSelectedCategory(v as StickerCategory);
+ }
+ }}
+ tabs={[
+ {
+ value: "all",
+ label: "All",
+ icon: <Grid3X3 className="h-3 w-3" />,
+ content: <StickersContentView category="all" />,
+ },
+ {
+ value: "general",
+ label: "Icons",
+ icon: <Sparkles className="h-3 w-3" />,
+ content: <StickersContentView category="general" />,
+ },
+ {
+ value: "brands",
+ label: "Brands",
+ icon: <Hash className="h-3 w-3" />,
+ content: <StickersContentView category="brands" />,
+ },
+ {
+ value: "emoji",
+ label: "Emoji",
+ icon: <Smile className="h-3 w-3" />,
+ content: <StickersContentView category="emoji" />,
+ },
+ ]}
+ className="flex flex-col h-full p-0 overflow-hidden"
+ />
);
}
@@ -204,12 +181,12 @@ function StickersContentView({ category }: { category: StickerCategory }) {
setSelectedCollection,
loadCollections,
searchStickers,
- downloadSticker,
+ addStickerToTimeline,
clearRecentStickers,
setSelectedCategory,
+ addingSticker,
} = useStickersStore();
- const [addingSticker, setAddingSticker] = useState<string | null>(null);
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const [collectionsToShow, setCollectionsToShow] = useState(20);
const [showCollectionItems, setShowCollectionItems] = useState(false);
@@ -273,48 +250,11 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}, [localSearchQuery]);
const handleAddSticker = async (iconName: string) => {
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- setAddingSticker(iconName);
-
try {
- const file = await downloadSticker(iconName);
-
- if (!file) {
- throw new Error("Failed to download sticker");
- }
-
- const mediaItem: Omit<MediaFile, "id"> = {
- name: iconName.replace(":", "-"),
- type: "image",
- file,
- url: URL.createObjectURL(file),
- width: 200,
- height: 200,
- duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
- ephemeral: false,
- };
-
- await addMediaFile(activeProject.id, mediaItem);
-
- const added = useMediaStore
- .getState()
- .mediaFiles.find(
- (m) => m.url === mediaItem.url && m.name === mediaItem.name
- );
- if (!added) throw new Error("Sticker not in media store");
-
- addElementAtTime(added, currentTime);
-
- toast.success(`Added "${iconName}" to timeline`);
+ await addStickerToTimeline(iconName);
} catch (error) {
console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline");
- } finally {
- setAddingSticker(null);
}
};
@@ -363,7 +303,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}, [isInCollection]);
return (
- <div className="flex flex-col gap-5 mt-1 h-full">
+ <div className="flex flex-col gap-5 mt-1 h-full p-4">
<div className="space-y-3">
<InputWithBack
isExpanded={isInCollection}
@@ -383,6 +323,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}
value={localSearchQuery}
onChange={setLocalSearchQuery}
+ disableAnimation={true}
/>
</div>