| 1 | "use client"; |
| 2 | |
| 3 | import { Check, ChevronRight, LayoutGrid, Menu, X } from "lucide-react"; |
| 4 | import { motion } from "motion/react"; |
| 5 | import { useCallback, useEffect, useState } from "react"; |
| 6 | |
| 7 | import { Button } from "@/components/ui/button"; |
| 8 | import { |
| 9 | Credenza, |
| 10 | CredenzaContent, |
| 11 | CredenzaHeader, |
| 12 | CredenzaTitle, |
| 13 | } from "@/components/ui/credenza"; |
| 14 | import { ScrollArea } from "@/components/ui/scroll-area"; |
| 15 | import { useMediaQuery } from "@/hooks/globals/useMediaQuery"; |
| 16 | import { cn } from "@/lib/utils"; |
| 17 | import { usePresentationState } from "@/states/presentation-state"; |
| 18 | import { |
| 19 | isTemplateSelected, |
| 20 | removeTemplateSelection, |
| 21 | TEMPLATE_CATEGORIES, |
| 22 | TEMPLATE_DEFINITIONS, |
| 23 | type TemplateDefinition, |
| 24 | } from "../../utils/templates"; |
| 25 | import { persistOutlineLayoutSelection } from "./persistOutlineLayoutSelection"; |
| 26 | |
| 27 | interface SelectableTemplateCardProps { |
| 28 | template: TemplateDefinition; |
| 29 | isSelected: boolean; |
| 30 | onToggle: () => void; |
| 31 | disabled?: boolean; |
| 32 | } |
| 33 | |
| 34 | function SelectableTemplateCard({ |
| 35 | template, |
| 36 | isSelected, |
| 37 | onToggle, |
| 38 | disabled, |
| 39 | }: SelectableTemplateCardProps) { |
| 40 | return ( |
| 41 | <motion.button |
| 42 | onClick={onToggle} |
| 43 | disabled={disabled} |
| 44 | className={cn( |
| 45 | "group relative flex flex-col gap-2 text-left", |
| 46 | disabled && !isSelected && "cursor-not-allowed opacity-50", |
| 47 | )} |
| 48 | whileHover={!disabled ? { scale: 1.02 } : undefined} |
| 49 | whileTap={!disabled ? { scale: 0.98 } : undefined} |
| 50 | > |
| 51 | <div |
| 52 | className={cn( |
| 53 | "aspect-4/3 w-full overflow-hidden rounded-lg border bg-card shadow transition-all", |
| 54 | isSelected |
| 55 | ? "border-primary ring-2 ring-primary/20" |
| 56 | : "border-border group-hover:border-primary/50 group-hover:shadow-md", |
| 57 | )} |
| 58 | > |
| 59 | {template.preview} |
| 60 | {/* Selection indicator */} |
| 61 | {isSelected && ( |
| 62 | <div className="absolute top-2 right-2 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground"> |
| 63 | <Check className="size-3" /> |
| 64 | </div> |
| 65 | )} |
| 66 | </div> |
| 67 | <div className="flex items-center gap-2"> |
| 68 | <span |
| 69 | className={cn( |
| 70 | "text-xs font-medium", |
| 71 | isSelected |
| 72 | ? "text-foreground" |
| 73 | : "text-muted-foreground group-hover:text-foreground", |
| 74 | )} |
| 75 | > |
| 76 | {template.name} |
| 77 | </span> |
| 78 | </div> |
| 79 | </motion.button> |
| 80 | ); |
| 81 | } |
| 82 | |
| 83 | interface OutlineTemplateModalProps { |
| 84 | isOpen: boolean; |
| 85 | onClose: () => void; |
| 86 | mode: "global" | "single"; |
| 87 | outlineId?: string; // Only for "single" mode |
| 88 | } |
| 89 | |
| 90 | export function OutlineTemplateModal({ |
| 91 | isOpen, |
| 92 | onClose, |
| 93 | mode, |
| 94 | outlineId, |
| 95 | }: OutlineTemplateModalProps) { |
| 96 | const isDesktop = useMediaQuery("(min-width: 768px)"); |
| 97 | const [selectedCategory, setSelectedCategory] = useState<string | null>(null); |
| 98 | const [isSidebarVisible, setIsSidebarVisible] = useState(false); |
| 99 | |
| 100 | const { |
| 101 | selectedSlideTemplates, |
| 102 | setSelectedSlideTemplates, |
| 103 | numSlides, |
| 104 | outlineTemplateOverrides, |
| 105 | setOutlineTemplateOverride, |
| 106 | } = usePresentationState(); |
| 107 | |
| 108 | const singleTemplateId = |
| 109 | mode === "single" && outlineId ? outlineTemplateOverrides[outlineId] : null; |
| 110 | |
| 111 | useEffect(() => { |
| 112 | if (isOpen) { |
| 113 | setIsSidebarVisible(isDesktop); |
| 114 | } |
| 115 | }, [isDesktop, isOpen]); |
| 116 | |
| 117 | const effectiveSelection = |
| 118 | mode === "global" |
| 119 | ? selectedSlideTemplates |
| 120 | : singleTemplateId |
| 121 | ? [singleTemplateId] |
| 122 | : []; |
| 123 | |
| 124 | const filteredTemplates = selectedCategory |
| 125 | ? TEMPLATE_DEFINITIONS.filter((t) => t.categoryId === selectedCategory) |
| 126 | : TEMPLATE_DEFINITIONS; |
| 127 | |
| 128 | const handleTemplateToggle = useCallback( |
| 129 | (templateId: string) => { |
| 130 | const template = TEMPLATE_DEFINITIONS.find( |
| 131 | (item) => item.id === templateId, |
| 132 | ); |
| 133 | |
| 134 | if (!template) { |
| 135 | return; |
| 136 | } |
| 137 | |
| 138 | if (mode === "global") { |
| 139 | const isSelected = isTemplateSelected(selectedSlideTemplates, template); |
| 140 | if (isSelected) { |
| 141 | setSelectedSlideTemplates( |
| 142 | removeTemplateSelection(selectedSlideTemplates, template), |
| 143 | ); |
| 144 | } else if (selectedSlideTemplates.length < numSlides) { |
| 145 | setSelectedSlideTemplates([...selectedSlideTemplates, templateId]); |
| 146 | } |
| 147 | |
| 148 | void persistOutlineLayoutSelection(); |
| 149 | } else { |
| 150 | // Single mode - one explicit layout per outline item. |
| 151 | const isSelected = singleTemplateId |
| 152 | ? isTemplateSelected([singleTemplateId], template) |
| 153 | : false; |
| 154 | const nextTemplateId = isSelected ? null : templateId; |
| 155 | |
| 156 | if (outlineId) { |
| 157 | setOutlineTemplateOverride(outlineId, nextTemplateId); |
| 158 | void persistOutlineLayoutSelection(); |
| 159 | } |
| 160 | } |
| 161 | }, |
| 162 | [ |
| 163 | mode, |
| 164 | selectedSlideTemplates, |
| 165 | setSelectedSlideTemplates, |
| 166 | numSlides, |
| 167 | singleTemplateId, |
| 168 | outlineId, |
| 169 | setOutlineTemplateOverride, |
| 170 | ], |
| 171 | ); |
| 172 | |
| 173 | const handleClearAll = () => { |
| 174 | if (mode === "global") { |
| 175 | setSelectedSlideTemplates([]); |
| 176 | } else if (outlineId) { |
| 177 | setOutlineTemplateOverride(outlineId, null); |
| 178 | } |
| 179 | |
| 180 | void persistOutlineLayoutSelection(); |
| 181 | }; |
| 182 | |
| 183 | const handleApply = () => { |
| 184 | onClose(); |
| 185 | }; |
| 186 | |
| 187 | const getTemplatesByCategory = (categoryId: string) => |
| 188 | TEMPLATE_DEFINITIONS.filter((t) => t.categoryId === categoryId); |
| 189 | |
| 190 | const selectionLimit = mode === "global" ? numSlides : 1; |
| 191 | const isAtLimit = |
| 192 | mode === "global" && effectiveSelection.length >= selectionLimit; |
| 193 | |
| 194 | const renderTemplateGrid = (templates: TemplateDefinition[]) => ( |
| 195 | <div className="grid grid-cols-2 gap-3 p-1 sm:grid-cols-3 lg:grid-cols-4 lg:gap-4"> |
| 196 | {templates.map((template) => ( |
| 197 | <SelectableTemplateCard |
| 198 | key={template.id} |
| 199 | template={template} |
| 200 | isSelected={isTemplateSelected(effectiveSelection, template)} |
| 201 | onToggle={() => handleTemplateToggle(template.id)} |
| 202 | disabled={ |
| 203 | isAtLimit && !isTemplateSelected(effectiveSelection, template) |
| 204 | } |
| 205 | /> |
| 206 | ))} |
| 207 | </div> |
| 208 | ); |
| 209 | |
| 210 | return ( |
| 211 | <Credenza |
| 212 | open={isOpen} |
| 213 | onOpenChange={(open) => { |
| 214 | if (!open) { |
| 215 | onClose(); |
| 216 | } |
| 217 | }} |
| 218 | > |
| 219 | <CredenzaContent |
| 220 | shouldHaveClose={false} |
| 221 | className="h-[92dvh] max-h-[92dvh] max-w-250 gap-0 overflow-hidden p-0 md:h-auto" |
| 222 | > |
| 223 | <CredenzaHeader className="border-b px-4 py-4 sm:px-6"> |
| 224 | <div className="flex items-start justify-between gap-3"> |
| 225 | <div className="flex min-w-0 items-center gap-3"> |
| 226 | {isDesktop && ( |
| 227 | <Button |
| 228 | onClick={() => setIsSidebarVisible(!isSidebarVisible)} |
| 229 | variant="ghost" |
| 230 | size="icon" |
| 231 | className="size-8" |
| 232 | > |
| 233 | <Menu className="size-4" /> |
| 234 | </Button> |
| 235 | )} |
| 236 | <div className="min-w-0"> |
| 237 | <CredenzaTitle> |
| 238 | {mode === "global" ? "Select Layouts" : "Select Layout"} |
| 239 | </CredenzaTitle> |
| 240 | <p className="mt-1 text-sm text-muted-foreground"> |
| 241 | {effectiveSelection.length}/{selectionLimit} selected |
| 242 | </p> |
| 243 | </div> |
| 244 | </div> |
| 245 | {isDesktop && ( |
| 246 | <div className="flex items-center gap-2"> |
| 247 | {effectiveSelection.length > 0 && ( |
| 248 | <Button |
| 249 | onClick={handleClearAll} |
| 250 | variant="outline" |
| 251 | size="sm" |
| 252 | className="h-8" |
| 253 | > |
| 254 | Clear All |
| 255 | </Button> |
| 256 | )} |
| 257 | <Button onClick={handleApply} size="sm" className="h-8"> |
| 258 | {mode === "global" ? "Done" : "Apply"} |
| 259 | </Button> |
| 260 | <Button |
| 261 | onClick={onClose} |
| 262 | variant="ghost" |
| 263 | size="icon" |
| 264 | className="size-8" |
| 265 | > |
| 266 | <X className="size-4" /> |
| 267 | </Button> |
| 268 | </div> |
| 269 | )} |
| 270 | {!isDesktop && ( |
| 271 | <Button |
| 272 | onClick={onClose} |
| 273 | variant="ghost" |
| 274 | size="icon" |
| 275 | className="size-8" |
| 276 | > |
| 277 | <X className="size-4" /> |
| 278 | </Button> |
| 279 | )} |
| 280 | </div> |
| 281 | </CredenzaHeader> |
| 282 | |
| 283 | {isDesktop ? ( |
| 284 | <div className="flex h-[70vh]"> |
| 285 | {/* Sidebar */} |
| 286 | {isSidebarVisible && ( |
| 287 | <div className="w-60 border-r bg-muted/30"> |
| 288 | <ScrollArea className="h-full space-y-2 p-3"> |
| 289 | <button |
| 290 | type="button" |
| 291 | onClick={() => setSelectedCategory(null)} |
| 292 | className={cn( |
| 293 | "mb-1 flex w-full items-center gap-2 rounded-lg p-4 text-sm transition-colors", |
| 294 | selectedCategory === null |
| 295 | ? "bg-primary text-primary-foreground" |
| 296 | : "hover:bg-muted", |
| 297 | )} |
| 298 | > |
| 299 | <LayoutGrid className="size-4" /> |
| 300 | <span>All Templates</span> |
| 301 | </button> |
| 302 | {TEMPLATE_CATEGORIES.map((category) => ( |
| 303 | <button |
| 304 | type="button" |
| 305 | key={category.id} |
| 306 | onClick={() => setSelectedCategory(category.id)} |
| 307 | className={cn( |
| 308 | "flex w-full items-center justify-between rounded-lg p-4 text-sm transition-colors", |
| 309 | selectedCategory === category.id |
| 310 | ? "bg-primary text-primary-foreground" |
| 311 | : "hover:bg-muted", |
| 312 | )} |
| 313 | > |
| 314 | <div className="flex items-center gap-2"> |
| 315 | {category.icon} |
| 316 | <span>{category.name}</span> |
| 317 | </div> |
| 318 | <ChevronRight className="size-4" /> |
| 319 | </button> |
| 320 | ))} |
| 321 | </ScrollArea> |
| 322 | </div> |
| 323 | )} |
| 324 | |
| 325 | {/* Content */} |
| 326 | <ScrollArea className="flex-1 overflow-x-visible p-5"> |
| 327 | {selectedCategory === null ? ( |
| 328 | <div className="space-y-8 px-1"> |
| 329 | {TEMPLATE_CATEGORIES.map((category) => { |
| 330 | const categoryTemplates = getTemplatesByCategory( |
| 331 | category.id, |
| 332 | ); |
| 333 | if (categoryTemplates.length === 0) return null; |
| 334 | return ( |
| 335 | <section key={category.id}> |
| 336 | <div className="mb-3 flex items-center gap-2"> |
| 337 | {category.icon} |
| 338 | <h3 className="text-sm font-medium"> |
| 339 | {category.name} |
| 340 | </h3> |
| 341 | </div> |
| 342 | {renderTemplateGrid(categoryTemplates)} |
| 343 | </section> |
| 344 | ); |
| 345 | })} |
| 346 | </div> |
| 347 | ) : ( |
| 348 | renderTemplateGrid(filteredTemplates) |
| 349 | )} |
| 350 | </ScrollArea> |
| 351 | </div> |
| 352 | ) : ( |
| 353 | <div className="flex min-h-0 flex-1 flex-col"> |
| 354 | <div className="overflow-x-auto border-b"> |
| 355 | <div className="flex w-max gap-2 px-4 py-3"> |
| 356 | <button |
| 357 | type="button" |
| 358 | onClick={() => setSelectedCategory(null)} |
| 359 | className={cn( |
| 360 | "flex shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors", |
| 361 | selectedCategory === null |
| 362 | ? "border-primary bg-primary text-primary-foreground" |
| 363 | : "border-border bg-background hover:bg-muted", |
| 364 | )} |
| 365 | > |
| 366 | <LayoutGrid className="size-4" /> |
| 367 | <span>All Layouts</span> |
| 368 | </button> |
| 369 | {TEMPLATE_CATEGORIES.map((category) => ( |
| 370 | <button |
| 371 | type="button" |
| 372 | key={category.id} |
| 373 | onClick={() => setSelectedCategory(category.id)} |
| 374 | className={cn( |
| 375 | "flex shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors", |
| 376 | selectedCategory === category.id |
| 377 | ? "border-primary bg-primary text-primary-foreground" |
| 378 | : "border-border bg-background hover:bg-muted", |
| 379 | )} |
| 380 | > |
| 381 | {category.icon} |
| 382 | <span>{category.name}</span> |
| 383 | </button> |
| 384 | ))} |
| 385 | </div> |
| 386 | </div> |
| 387 | |
| 388 | <ScrollArea className="min-h-0 flex-1 px-4 py-4"> |
| 389 | {selectedCategory === null ? ( |
| 390 | <div className="space-y-6"> |
| 391 | {TEMPLATE_CATEGORIES.map((category) => { |
| 392 | const categoryTemplates = getTemplatesByCategory( |
| 393 | category.id, |
| 394 | ); |
| 395 | if (categoryTemplates.length === 0) return null; |
| 396 | return ( |
| 397 | <section key={category.id}> |
| 398 | <div className="mb-3 flex items-center gap-2"> |
| 399 | {category.icon} |
| 400 | <h3 className="text-sm font-medium"> |
| 401 | {category.name} |
| 402 | </h3> |
| 403 | </div> |
| 404 | {renderTemplateGrid(categoryTemplates)} |
| 405 | </section> |
| 406 | ); |
| 407 | })} |
| 408 | </div> |
| 409 | ) : ( |
| 410 | renderTemplateGrid(filteredTemplates) |
| 411 | )} |
| 412 | </ScrollArea> |
| 413 | |
| 414 | <div className="flex flex-col gap-2 border-t px-4 py-3"> |
| 415 | {effectiveSelection.length > 0 && ( |
| 416 | <Button onClick={handleClearAll} variant="outline"> |
| 417 | Clear All |
| 418 | </Button> |
| 419 | )} |
| 420 | <Button onClick={handleApply}> |
| 421 | {mode === "global" ? "Done" : "Apply"} |
| 422 | </Button> |
| 423 | </div> |
| 424 | </div> |
| 425 | )} |
| 426 | </CredenzaContent> |
| 427 | </Credenza> |
| 428 | ); |
| 429 | } |
| 430 |