| 1 | "use client"; |
| 2 | |
| 3 | import { closestCenter, DndContext } from "@dnd-kit/core"; |
| 4 | import { |
| 5 | SortableContext, |
| 6 | verticalListSortingStrategy, |
| 7 | } from "@dnd-kit/sortable"; |
| 8 | import { Plus, Presentation } from "lucide-react"; |
| 9 | |
| 10 | import { calculateHeightFromRatio } from "@/config/slideFormats"; |
| 11 | import { usePresentationNavigation } from "@/hooks/presentation/usePresentationNavigation"; |
| 12 | import { usePresentationSlides } from "@/hooks/presentation/usePresentationSlides"; |
| 13 | import { usePresentingLoadingGate } from "@/hooks/presentation/usePresentingLoadingGate"; |
| 14 | import { usePresentModeOrientation } from "@/hooks/presentation/usePresentModeOrientation"; |
| 15 | import { useSlideChangeWatcher } from "@/hooks/presentation/useSlideChangeWatcher"; |
| 16 | import { useSlideContentScaling } from "@/hooks/presentation/useSlideContentScaling"; |
| 17 | import { useSlideOperations } from "@/hooks/presentation/useSlideOperations"; |
| 18 | import { usePresentationState } from "@/states/presentation-state"; |
| 19 | import { Button } from "../../ui/button"; |
| 20 | import { Skeleton } from "../../ui/skeleton"; |
| 21 | import { PresentModeHeader } from "../present-mode/PresentModeHeader"; |
| 22 | import { PresentModePhoneOverlay } from "../present-mode/PresentModePhoneOverlay"; |
| 23 | import { PresentModeProgressBar } from "../present-mode/PresentModeProgressBar"; |
| 24 | import { PresentationThumbnailCaptureManager } from "./PresentationThumbnailCaptureManager"; |
| 25 | import { SlideItem } from "./SlideItem"; |
| 26 | |
| 27 | interface SlidesContainerProps { |
| 28 | isGeneratingPresentation: boolean; |
| 29 | isReadOnly?: boolean; |
| 30 | } |
| 31 | |
| 32 | export const SlidesContainer = ({ |
| 33 | isGeneratingPresentation, |
| 34 | isReadOnly = false, |
| 35 | }: SlidesContainerProps) => { |
| 36 | const isPresenting = usePresentationState((s) => s.isPresenting); |
| 37 | const isPresentingLoading = usePresentationState( |
| 38 | (s) => s.isPresentingLoading, |
| 39 | ); |
| 40 | const zoomLevel = usePresentationState((s) => s.zoomLevel); |
| 41 | const presentingScaleLocks = usePresentationState( |
| 42 | (s) => s.presentingScaleLocks, |
| 43 | ); |
| 44 | const setIsPresentingLoading = usePresentationState( |
| 45 | (s) => s.setIsPresentingLoading, |
| 46 | ); |
| 47 | const currentPresentationTitle = usePresentationState( |
| 48 | (s) => s.currentPresentationTitle, |
| 49 | ); |
| 50 | const shouldShowExitHeader = usePresentationState( |
| 51 | (s) => s.shouldShowExitHeader, |
| 52 | ); |
| 53 | |
| 54 | // Use slideIds for rendering to prevent re-renders when slide content changes |
| 55 | const { slideIds, items, sensors, handleDragStart, handleDragEnd } = |
| 56 | usePresentationSlides(); |
| 57 | const { addFirstSlide } = useSlideOperations(); |
| 58 | |
| 59 | // Use the slide change watcher to automatically save changes (disabled during generation) |
| 60 | useSlideChangeWatcher({ |
| 61 | debounceDelay: 600, |
| 62 | enabled: !isGeneratingPresentation && !isReadOnly, |
| 63 | }); |
| 64 | |
| 65 | // Handle presentation navigation (keyboard and mouse) |
| 66 | usePresentationNavigation(); |
| 67 | const { isPhoneViewport } = usePresentModeOrientation(isPresenting); |
| 68 | |
| 69 | const slidesCount = slideIds.length; |
| 70 | const generatingPlaceholderScaling = useSlideContentScaling( |
| 71 | "M", |
| 72 | false, |
| 73 | "presentation", |
| 74 | { type: "ratio", value: "16:9" }, |
| 75 | undefined, |
| 76 | zoomLevel, |
| 77 | ); |
| 78 | const generatingPlaceholderWidth = Math.round( |
| 79 | generatingPlaceholderScaling.slideWidth * |
| 80 | Math.max(generatingPlaceholderScaling.scale, 0.1), |
| 81 | ); |
| 82 | const generatingPlaceholderHeight = Math.round( |
| 83 | (calculateHeightFromRatio(generatingPlaceholderScaling.slideWidth, { |
| 84 | type: "ratio", |
| 85 | value: "16:9", |
| 86 | }).minHeightPx ?? 0) * Math.max(generatingPlaceholderScaling.scale, 0.1), |
| 87 | ); |
| 88 | |
| 89 | usePresentingLoadingGate({ |
| 90 | isPresenting, |
| 91 | isPresentingLoading, |
| 92 | slideIds, |
| 93 | presentingScaleLocks, |
| 94 | setIsPresentingLoading, |
| 95 | clearDelayMs: 150, |
| 96 | }); |
| 97 | |
| 98 | return ( |
| 99 | <DndContext |
| 100 | sensors={sensors} |
| 101 | collisionDetection={closestCenter} |
| 102 | onDragStart={handleDragStart} |
| 103 | onDragEnd={handleDragEnd} |
| 104 | > |
| 105 | <SortableContext items={items} strategy={verticalListSortingStrategy}> |
| 106 | {!isReadOnly && <PresentationThumbnailCaptureManager />} |
| 107 | <PresentModeHeader |
| 108 | presentationTitle={currentPresentationTitle} |
| 109 | showHeader={isPresenting && shouldShowExitHeader} |
| 110 | /> |
| 111 | {isGeneratingPresentation && slidesCount === 0 && ( |
| 112 | <div className="flex w-full justify-center"> |
| 113 | <div |
| 114 | className="relative max-w-full" |
| 115 | style={{ |
| 116 | width: `${generatingPlaceholderWidth}px`, |
| 117 | height: `${generatingPlaceholderHeight}px`, |
| 118 | }} |
| 119 | > |
| 120 | <Skeleton className="h-full w-full rounded-md" /> |
| 121 | </div> |
| 122 | </div> |
| 123 | )} |
| 124 | {!isGeneratingPresentation && |
| 125 | !isPresenting && |
| 126 | slidesCount === 0 && |
| 127 | !isReadOnly && ( |
| 128 | <div className="mx-auto w-full max-w-5xl"> |
| 129 | <div className="relative flex aspect-video w-full flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-background/85 p-8 text-center shadow"> |
| 130 | <div className="absolute inset-5 rounded-xl border border-dashed border-border/50" /> |
| 131 | <div className="relative z-10 flex flex-col items-center gap-4"> |
| 132 | <span className="rounded-full border border-border/70 bg-muted/70 p-3"> |
| 133 | <Presentation className="size-6 text-muted-foreground" /> |
| 134 | </span> |
| 135 | <div className="space-y-1"> |
| 136 | <p className="text-base font-semibold"> |
| 137 | No slides yet |
| 138 | </p> |
| 139 | <p className="text-sm text-muted-foreground"> |
| 140 | Start this presentation by adding your first slide. |
| 141 | </p> |
| 142 | </div> |
| 143 | <Button |
| 144 | onClick={() => addFirstSlide()} |
| 145 | className="gap-2" |
| 146 | size="sm" |
| 147 | > |
| 148 | <Plus className="size-4" /> |
| 149 | Add first slide |
| 150 | </Button> |
| 151 | </div> |
| 152 | </div> |
| 153 | </div> |
| 154 | )} |
| 155 | {slideIds.map((slideId) => ( |
| 156 | <SlideItem |
| 157 | key={slideId} |
| 158 | slideId={slideId} |
| 159 | isGeneratingPresentation={isGeneratingPresentation} |
| 160 | slidesCount={slidesCount} |
| 161 | isReadOnly={isReadOnly} |
| 162 | /> |
| 163 | ))} |
| 164 | |
| 165 | {isPresenting && ( |
| 166 | <PresentModePhoneOverlay |
| 167 | slideIds={slideIds} |
| 168 | isPhoneViewport={isPhoneViewport} |
| 169 | /> |
| 170 | )} |
| 171 | {isPresenting && <PresentModeProgressBar slideIds={slideIds} />} |
| 172 | </SortableContext> |
| 173 | </DndContext> |
| 174 | ); |
| 175 | }; |
| 176 |