346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
"use client";
|
|
import React, {
|
|
useEffect,
|
|
useState,
|
|
memo,
|
|
useCallback,
|
|
useRef,
|
|
} from "react";
|
|
import { useDispatch, useSelector } from "react-redux";
|
|
import { addNewSlide } from "@/store/slices/presentationGeneration";
|
|
import { Loader2, Plus, X } from "lucide-react";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { notify } from "@/components/ui/sonner";
|
|
|
|
import { usePathname } from "next/navigation";
|
|
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
|
import { RootState } from "@/store/store";
|
|
import { TemplateV2HtmlSlidePreview } from "../../components/TemplateV2HtmlSlidePreview";
|
|
import {
|
|
extractTemplateV2Layouts,
|
|
type TemplateV2Layout,
|
|
} from "@/components/slide-editor/importing/template-v2-import";
|
|
import {
|
|
BLANK_SLIDE_LAYOUT_ID,
|
|
BLANK_TEMPLATE_V2_LAYOUT,
|
|
} from "../../_shared/blank-slide";
|
|
import { MAX_NUMBER_OF_SLIDES } from "@/utils/presentationLimits";
|
|
|
|
interface LayoutItemProps {
|
|
layout: any;
|
|
onSelect: (sampleData: any, layoutId: string) => void;
|
|
}
|
|
|
|
const PREVIEW_WIDTH = 1280;
|
|
const PREVIEW_HEIGHT = 720;
|
|
const EMPTY_SLIDE_LAYOUT = {
|
|
layoutId: BLANK_SLIDE_LAYOUT_ID,
|
|
layoutName: "Empty Slide",
|
|
sampleData: BLANK_TEMPLATE_V2_LAYOUT,
|
|
isEmptySlide: true,
|
|
};
|
|
|
|
function createTemplateV2LayoutItem(layout: TemplateV2Layout, layoutIndex: number) {
|
|
const layoutId =
|
|
typeof layout.id === "string" && layout.id.trim()
|
|
? layout.id
|
|
: `layout_${layoutIndex + 1}`;
|
|
const description =
|
|
typeof layout.description === "string" && layout.description.trim()
|
|
? layout.description
|
|
: null;
|
|
|
|
return {
|
|
layoutId,
|
|
layoutName: description ?? layoutId,
|
|
sampleData: layout,
|
|
v2Layout: layout,
|
|
};
|
|
}
|
|
|
|
function createTemplateV2PreviewSlide(layout: TemplateV2Layout, layoutId: string) {
|
|
return {
|
|
id: `template-v2-preview-${layoutId}`,
|
|
content: {},
|
|
ui: layout,
|
|
layout: layoutId,
|
|
layout_group: "template-v2",
|
|
};
|
|
}
|
|
|
|
const LayoutItem = memo(({ layout, onSelect }: LayoutItemProps) => {
|
|
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
const [scale, setScale] = useState(0.2);
|
|
const {
|
|
component: LayoutComponent,
|
|
sampleData,
|
|
layoutId,
|
|
layoutName,
|
|
v2Layout,
|
|
isEmptySlide,
|
|
} = layout;
|
|
|
|
useEffect(() => {
|
|
if (!previewRef.current) return;
|
|
|
|
const previewElement = previewRef.current;
|
|
const updateScale = () => {
|
|
const nextScale = Math.min(
|
|
previewElement.clientWidth / PREVIEW_WIDTH,
|
|
previewElement.clientHeight / PREVIEW_HEIGHT
|
|
);
|
|
setScale(nextScale || 0.2);
|
|
};
|
|
|
|
const resizeObserver = new ResizeObserver(updateScale);
|
|
resizeObserver.observe(previewElement);
|
|
updateScale();
|
|
|
|
return () => resizeObserver.disconnect();
|
|
}, []);
|
|
|
|
const selectLayout = () => onSelect(sampleData, layoutId);
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Add ${layoutName || "slide"} layout`}
|
|
title={layoutName || "Slide layout"}
|
|
onClick={selectLayout}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
event.preventDefault();
|
|
selectLayout();
|
|
}}
|
|
className="relative aspect-video cursor-pointer overflow-hidden rounded-md border border-[#E4E4EA] bg-white shadow-[0_1px_2px_rgba(16,24,40,0.04)] outline-none transition duration-200 hover:border-[#7C51F8] hover:shadow-[0_0_0_2px_rgba(124,81,248,0.18)] focus-visible:ring-2 focus-visible:ring-[#7C51F8]"
|
|
>
|
|
<div className="absolute inset-0 z-40 bg-transparent" />
|
|
<div ref={previewRef} className="relative h-full w-full overflow-hidden">
|
|
<div
|
|
className="absolute left-0 top-0"
|
|
style={{
|
|
width: isEmptySlide ? "100%" : PREVIEW_WIDTH,
|
|
height: isEmptySlide ? "100%" : PREVIEW_HEIGHT,
|
|
transform: isEmptySlide ? undefined : `scale(${scale})`,
|
|
transformOrigin: "top left",
|
|
}}
|
|
>
|
|
{isEmptySlide ? (
|
|
<div className="flex h-full w-full items-center justify-center bg-white">
|
|
<div className="flex flex-col items-center gap-2 text-[#191919]">
|
|
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-[#E2E2EA] bg-[#FAFAFB]">
|
|
<Plus className="h-4 w-4" />
|
|
</span>
|
|
<span className="text-sm font-medium">Empty Slide</span>
|
|
</div>
|
|
</div>
|
|
) : v2Layout ? (
|
|
<TemplateV2HtmlSlidePreview
|
|
slide={createTemplateV2PreviewSlide(v2Layout, layoutId)}
|
|
fixedSize
|
|
/>
|
|
) : LayoutComponent ? (
|
|
<LayoutComponent data={sampleData} />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
LayoutItem.displayName = "LayoutItem";
|
|
interface NewSlideV1Props {
|
|
setShowNewSlideSelection: (show: boolean) => void;
|
|
templateID: string;
|
|
index: number;
|
|
presentationId: string;
|
|
onSlideAdded?: (
|
|
index: number,
|
|
options?: {
|
|
promptOverlaySlideId?: string;
|
|
promptOverlayKind?: "blank" | "layout";
|
|
},
|
|
) => void;
|
|
}
|
|
const NewSlideV1 = ({
|
|
setShowNewSlideSelection,
|
|
templateID,
|
|
index,
|
|
presentationId,
|
|
onSlideAdded,
|
|
}: NewSlideV1Props) => {
|
|
const dispatch = useDispatch();
|
|
const pathname = usePathname();
|
|
const presentationLayout = useSelector(
|
|
(state: RootState) => state.presentationGeneration.presentationData?.layout
|
|
);
|
|
const slideCount = useSelector((state: RootState) => {
|
|
const slides = state.presentationGeneration.presentationData?.slides;
|
|
return Array.isArray(slides) ? slides.length : 0;
|
|
});
|
|
const [layouts, setLayouts] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setShowNewSlideSelection(false);
|
|
};
|
|
|
|
window.addEventListener("keydown", handleEscape);
|
|
return () => window.removeEventListener("keydown", handleEscape);
|
|
}, [setShowNewSlideSelection]);
|
|
|
|
const handleNewSlide = useCallback(
|
|
(sampleData: any, id: string) => {
|
|
if (slideCount >= MAX_NUMBER_OF_SLIDES) {
|
|
notify.warning(
|
|
"Slide limit reached",
|
|
`You can have up to ${MAX_NUMBER_OF_SLIDES} slides.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const slideId = uuidv4();
|
|
const newSlide = {
|
|
id: slideId,
|
|
index,
|
|
content: {},
|
|
ui: sampleData,
|
|
layout_group: templateID,
|
|
layout: id,
|
|
presentation: presentationId,
|
|
};
|
|
|
|
dispatch(addNewSlide({ slideData: newSlide, index }));
|
|
onSlideAdded?.(
|
|
index + 1,
|
|
{
|
|
promptOverlaySlideId: slideId,
|
|
promptOverlayKind:
|
|
id === BLANK_SLIDE_LAYOUT_ID ? "blank" : "layout",
|
|
},
|
|
);
|
|
trackEvent(MixpanelEvent.Presentation_Slide_Added, {
|
|
pathname,
|
|
presentation_id: presentationId,
|
|
inserted_after_index: index,
|
|
template_id: templateID,
|
|
layout_id: id,
|
|
});
|
|
setShowNewSlideSelection(false);
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
notify.error("Could not add slide", "Something went wrong while adding the new slide.");
|
|
}
|
|
},
|
|
[
|
|
index,
|
|
templateID,
|
|
presentationId,
|
|
dispatch,
|
|
setShowNewSlideSelection,
|
|
pathname,
|
|
onSlideAdded,
|
|
slideCount,
|
|
]
|
|
);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const fetchLayouts = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
const templateV2Layouts = extractTemplateV2Layouts(presentationLayout);
|
|
const layoutItems = templateV2Layouts.map((layout, layoutIndex) =>
|
|
createTemplateV2LayoutItem(layout, layoutIndex)
|
|
);
|
|
if (isMounted) setLayouts(layoutItems);
|
|
|
|
} catch (error) {
|
|
console.error("Error loading slide layouts:", error);
|
|
if (isMounted) setLayouts([]);
|
|
} finally {
|
|
if (isMounted) setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchLayouts();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [presentationLayout]);
|
|
|
|
const showEmptySlideLayout = true;
|
|
const selectableLayouts = showEmptySlideLayout
|
|
? [EMPTY_SLIDE_LAYOUT, ...layouts]
|
|
: layouts;
|
|
const layoutCountText = showEmptySlideLayout
|
|
? `${selectableLayouts.length} Option${selectableLayouts.length === 1 ? "" : "s"}`
|
|
: `${layouts.length} Layout${layouts.length === 1 ? "" : "s"}`;
|
|
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="choose-slide-layout-title"
|
|
className="relative w-full rounded-[14px] border border-[#EDEEEF] bg-white font-syne shadow-[0_18px_60px_rgba(15,23,42,0.18)]"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label="Close layout picker"
|
|
onClick={() => setShowNewSlideSelection(false)}
|
|
className="absolute right-0 top-[-52px] z-50 flex h-10 w-10 items-center justify-center rounded-full border border-[#EDEEEF] bg-white text-[#191919] shadow-[0_6.6px_13.2px_rgba(0,0,0,0.10)] transition hover:bg-[#F7F6F9]"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
|
|
<div className="flex min-h-[64px] items-center justify-between border-b border-[#EDEEEF] px-5 py-4 md:px-6">
|
|
<div>
|
|
<h2
|
|
id="choose-slide-layout-title"
|
|
className="text-base font-medium leading-tight text-[#191919]"
|
|
>
|
|
Choose Slide Layout
|
|
</h2>
|
|
<p className="mt-1 text-xs font-normal leading-none text-[#7A7A85]">
|
|
{loading ? "Loading layouts" : layoutCountText}
|
|
</p>
|
|
</div>
|
|
{loading && (
|
|
<Loader2 className="h-5 w-5 animate-spin text-[#7C51F8]" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="max-h-[min(70vh,640px)] overflow-y-auto px-4 py-4 md:px-5">
|
|
{loading ? (
|
|
<div className="flex h-56 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-[#7C51F8]" />
|
|
</div>
|
|
) : selectableLayouts.length > 0 ? (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{selectableLayouts.map((layout: any) => (
|
|
<LayoutItem
|
|
key={layout.layoutId}
|
|
layout={layout}
|
|
onSelect={handleNewSlide}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-56 items-center justify-center rounded-lg border border-dashed border-[#D9D9E1] bg-[#FAFAFB] text-sm text-[#7A7A85]">
|
|
No layouts available.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default NewSlideV1;
|