返回 presentation-ai
MediaEmbedPanel.tsx
根目录 / src / components / presentation / edit-panel / sections / MediaEmbedPanel.tsx
1 "use client";
2
3 import { DRAG_ITEM_BLOCK } from "@platejs/dnd";
4 import { GripVertical } from "lucide-react";
5 import { useMemo, useState } from "react";
6 import { useDrag } from "react-dnd";
7 import { toast } from "sonner";
8
9 import {
10 createMediaEmbedNode,
11 mediaEmbedItems,
12 type MediaEmbedItem,
13 } from "@/components/plate/ui/media-embeds";
14 import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave";
15 import { cn } from "@/lib/utils";
16 import { usePresentationState } from "@/states/presentation-state";
17 import { PanelSearchFilter } from "./PanelSearchFilter";
18 import { matchesPanelSearch } from "./PanelSearchFilter";
19
20 function getMediaFilterValue(item: MediaEmbedItem): string {
21 if (["youtube", "vimeo", "loom"].includes(item.embedType)) return "video";
22 if (["twitter"].includes(item.embedType)) return "social";
23 if (["figma", "codepen"].includes(item.embedType)) return "design";
24 if (["image", "infographic"].includes(item.embedType)) return "media";
25 return "web";
26 }
27
28 export function MediaEmbedPanel() {
29 const [searchQuery, setSearchQuery] = useState("");
30 const filteredItems = useMemo(
31 () =>
32 mediaEmbedItems.filter((item) => {
33 const category = getMediaFilterValue(item);
34
35 return matchesPanelSearch(searchQuery, [
36 item.label,
37 item.description,
38 item.embedType,
39 category,
40 ]);
41 }),
42 [searchQuery],
43 );
44
45 return (
46 <div className="flex h-full flex-col overflow-hidden">
47 <PanelSearchFilter
48 onQueryChange={setSearchQuery}
49 placeholder="Search embeds..."
50 query={searchQuery}
51 />
52 <div className="scrollbar-thin flex-1 overflow-y-auto px-4 py-4 scrollbar-thumb-primary scrollbar-track-transparent">
53 {filteredItems.length > 0 ? (
54 <div className="grid grid-cols-2 gap-4">
55 {filteredItems.map((item) => (
56 <MediaEmbedCard key={item.key} item={item} />
57 ))}
58 </div>
59 ) : (
60 <div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
61 No embeds match your search.
62 </div>
63 )}
64 </div>
65 </div>
66 );
67 }
68
69 function MediaEmbedCard({ item }: { item: MediaEmbedItem }) {
70 const currentSlideId = usePresentationState((s) => s.currentSlideId);
71 const slides = usePresentationState((s) => s.slides);
72 const setSlides = usePresentationState((s) => s.setSlides);
73 const openInfographicGenerationEditor = usePresentationState(
74 (s) => s.openInfographicGenerationEditor,
75 );
76 const { saveImmediately } = useDebouncedSave();
77
78 const [{ isDragging }, drag] = useDrag(() => ({
79 type: DRAG_ITEM_BLOCK,
80 item: {
81 id: `external-media-${item.key}`,
82 element: createMediaEmbedNode(item.embedType),
83 },
84 collect: (monitor) => ({ isDragging: monitor.isDragging() }),
85 }));
86
87 const selectEmbedType = () => {
88 const currentSlide = slides.find((s) => s.id === currentSlideId);
89
90 if (currentSlide) {
91 setSlides(
92 slides.map((slide) =>
93 slide.id === currentSlideId
94 ? {
95 ...slide,
96 rootImage: {
97 ...(slide.rootImage ?? { query: "" }),
98 embedType: item.embedType,
99 url: "", // Clear URL so user can enter a new one
100 chartType: undefined, // Clear chart type since we're switching to embed
101 chartData: undefined,
102 chartOptions: undefined,
103 },
104 }
105 : slide,
106 ),
107 );
108 void saveImmediately();
109 toast.success(`Changed to ${item.label} embed`);
110 if (item.embedType === "infographic") {
111 openInfographicGenerationEditor();
112 }
113 } else {
114 toast.error("Please select a root image first");
115 }
116 };
117
118 return (
119 <div
120 ref={(el) => {
121 if (el) drag(el);
122 }}
123 onClick={selectEmbedType}
124 className={cn(
125 "group relative flex cursor-pointer flex-col items-center justify-center space-y-3 rounded-xl border border-border/50 p-5 transition-all duration-300 hover:shadow-lg",
126 "bg-card hover:bg-secondary/50",
127 "hover:-translate-y-1 hover:scale-105 hover:border-border",
128 isDragging && "scale-95 opacity-50",
129 )}
130 >
131 <div className="absolute top-2 left-2">
132 <GripVertical className="size-4 cursor-grab text-muted-foreground transition-colors group-hover:text-foreground active:cursor-grabbing" />
133 </div>
134 <div
135 className={cn(
136 "flex size-14 items-center justify-center rounded-lg text-3xl shadow transition-all duration-300 group-hover:scale-110",
137 "bg-muted",
138 )}
139 >
140 {item.icon}
141 </div>
142 <div className="text-center">
143 <div className="text-sm font-bold text-foreground">{item.label}</div>
144 <div className="mt-1 text-xs text-muted-foreground">
145 {item.description}
146 </div>
147 </div>
148 </div>
149 );
150 }
151
151 lines Plain Text