| 1 | import { |
| 2 | closestCenter, |
| 3 | DndContext, |
| 4 | KeyboardSensor, |
| 5 | PointerSensor, |
| 6 | useSensor, |
| 7 | useSensors, |
| 8 | type DragEndEvent, |
| 9 | } from "@dnd-kit/core"; |
| 10 | import { |
| 11 | arrayMove, |
| 12 | SortableContext, |
| 13 | sortableKeyboardCoordinates, |
| 14 | verticalListSortingStrategy, |
| 15 | } from "@dnd-kit/sortable"; |
| 16 | import { LayoutGrid, Plus } from "lucide-react"; |
| 17 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 18 | |
| 19 | import { Badge } from "@/components/ui/badge"; |
| 20 | import { Button } from "@/components/ui/button"; |
| 21 | import { Skeleton } from "@/components/ui/skeleton"; |
| 22 | import { usePresentationState } from "@/states/presentation-state"; |
| 23 | import { |
| 24 | getTemplateSelectionIds, |
| 25 | TEMPLATE_DEFINITIONS, |
| 26 | } from "../../utils/templates"; |
| 27 | import { OutlineItem } from "./OutlineItem"; |
| 28 | import { OutlineTemplateModal } from "./OutlineTemplateModal"; |
| 29 | import { persistOutlineLayoutSelection } from "./persistOutlineLayoutSelection"; |
| 30 | |
| 31 | interface OutlineItemType { |
| 32 | id: string; |
| 33 | title: string; |
| 34 | } |
| 35 | |
| 36 | function areItemTitlesEqual( |
| 37 | items: OutlineItemType[], |
| 38 | titles: string[], |
| 39 | ): boolean { |
| 40 | return ( |
| 41 | items.length === titles.length && |
| 42 | items.every((item, index) => item.title === titles[index]) |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | function areDefaultOutlineIds(items: OutlineItemType[]): boolean { |
| 47 | return items.every((item, index) => item.id === (index + 1).toString()); |
| 48 | } |
| 49 | |
| 50 | function shouldAdoptPersistedOutlineIds( |
| 51 | items: OutlineItemType[], |
| 52 | persistedIds: string[], |
| 53 | ): boolean { |
| 54 | return ( |
| 55 | persistedIds.length === items.length && |
| 56 | areDefaultOutlineIds(items) && |
| 57 | items.some((item, index) => item.id !== persistedIds[index]) |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | function createOutlineItems( |
| 62 | titles: string[], |
| 63 | existingItems: OutlineItemType[] = [], |
| 64 | persistedIds: string[] = [], |
| 65 | ): OutlineItemType[] { |
| 66 | return titles.map((title, index) => ({ |
| 67 | id: |
| 68 | existingItems[index]?.id ?? persistedIds[index] ?? (index + 1).toString(), |
| 69 | title, |
| 70 | })); |
| 71 | } |
| 72 | |
| 73 | export function OutlineList() { |
| 74 | const { |
| 75 | outline: initialItems, |
| 76 | setOutline, |
| 77 | numSlides, |
| 78 | isGeneratingOutline, |
| 79 | webSearchEnabled, |
| 80 | selectedSlideTemplates, |
| 81 | outlineItemIds, |
| 82 | outlineTemplateOverrides, |
| 83 | setOutlineTemplateOverride, |
| 84 | setOutlineItemIds, |
| 85 | } = usePresentationState(); |
| 86 | |
| 87 | const [items, setItems] = useState<OutlineItemType[]>(() => |
| 88 | createOutlineItems(initialItems, [], outlineItemIds), |
| 89 | ); |
| 90 | |
| 91 | const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false); |
| 92 | const [singleModeOutlineId, setSingleModeOutlineId] = useState<string | null>( |
| 93 | null, |
| 94 | ); |
| 95 | |
| 96 | // Get available templates for dropdown (filtered to selected ones) |
| 97 | const availableTemplates = useMemo(() => { |
| 98 | const selectedTemplates: Array<{ id: string; name: string }> = []; |
| 99 | const selectedTemplateIds = new Set(selectedSlideTemplates); |
| 100 | |
| 101 | for (const template of TEMPLATE_DEFINITIONS) { |
| 102 | const hasSelectedId = getTemplateSelectionIds(template).some((id) => |
| 103 | selectedTemplateIds.has(id), |
| 104 | ); |
| 105 | |
| 106 | if (hasSelectedId) { |
| 107 | selectedTemplates.push({ id: template.id, name: template.name }); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | return selectedTemplates; |
| 112 | }, [selectedSlideTemplates]); |
| 113 | |
| 114 | useEffect(() => { |
| 115 | setItems((previousItems) => { |
| 116 | if (areItemTitlesEqual(previousItems, initialItems)) { |
| 117 | if (shouldAdoptPersistedOutlineIds(previousItems, outlineItemIds)) { |
| 118 | return createOutlineItems(initialItems, [], outlineItemIds); |
| 119 | } |
| 120 | |
| 121 | return previousItems; |
| 122 | } |
| 123 | |
| 124 | return createOutlineItems(initialItems, previousItems, outlineItemIds); |
| 125 | }); |
| 126 | }, [initialItems, outlineItemIds]); |
| 127 | |
| 128 | useEffect(() => { |
| 129 | setOutlineItemIds(items.map((item) => item.id)); |
| 130 | }, [items, setOutlineItemIds]); |
| 131 | |
| 132 | const sensors = useSensors( |
| 133 | useSensor(PointerSensor), |
| 134 | useSensor(KeyboardSensor, { |
| 135 | coordinateGetter: sortableKeyboardCoordinates, |
| 136 | }), |
| 137 | ); |
| 138 | |
| 139 | const syncOutline = useCallback( |
| 140 | (nextItems: OutlineItemType[]) => { |
| 141 | setOutline(nextItems.map((item) => item.title)); |
| 142 | }, |
| 143 | [setOutline], |
| 144 | ); |
| 145 | |
| 146 | const handleDragEnd = useCallback( |
| 147 | (event: DragEndEvent) => { |
| 148 | const { active, over } = event; |
| 149 | |
| 150 | if (over && active.id !== over.id) { |
| 151 | const oldIndex = items.findIndex((item) => item.id === active.id); |
| 152 | const newIndex = items.findIndex((item) => item.id === over.id); |
| 153 | |
| 154 | if (oldIndex < 0 || newIndex < 0) { |
| 155 | return; |
| 156 | } |
| 157 | |
| 158 | const nextItems = arrayMove(items, oldIndex, newIndex); |
| 159 | setItems(nextItems); |
| 160 | syncOutline(nextItems); |
| 161 | } |
| 162 | }, |
| 163 | [items, syncOutline], |
| 164 | ); |
| 165 | |
| 166 | const handleTitleChange = useCallback( |
| 167 | (id: string, newTitle: string) => { |
| 168 | const nextItems = items.map((item) => |
| 169 | item.id === id ? { ...item, title: newTitle } : item, |
| 170 | ); |
| 171 | setItems(nextItems); |
| 172 | syncOutline(nextItems); |
| 173 | }, |
| 174 | [items, syncOutline], |
| 175 | ); |
| 176 | |
| 177 | const handleAddCard = useCallback(() => { |
| 178 | const newId = crypto.randomUUID(); |
| 179 | const nextItems = [...items, { id: newId, title: "New Card" }]; |
| 180 | setItems(nextItems); |
| 181 | syncOutline(nextItems); |
| 182 | }, [items, syncOutline]); |
| 183 | |
| 184 | const handleDeleteCard = useCallback( |
| 185 | (id: string) => { |
| 186 | const nextItems = items.filter((item) => item.id !== id); |
| 187 | setItems(nextItems); |
| 188 | syncOutline(nextItems); |
| 189 | }, |
| 190 | [items, syncOutline], |
| 191 | ); |
| 192 | |
| 193 | const handleTemplateChange = useCallback( |
| 194 | (outlineId: string, templateId: string | null) => { |
| 195 | setOutlineTemplateOverride(outlineId, templateId); |
| 196 | void persistOutlineLayoutSelection(); |
| 197 | }, |
| 198 | [setOutlineTemplateOverride], |
| 199 | ); |
| 200 | |
| 201 | const content = useMemo(() => { |
| 202 | const totalSlides = numSlides; |
| 203 | const loadedCount = items.length; |
| 204 | const remainingCount = Math.max(0, totalSlides - loadedCount); |
| 205 | |
| 206 | const showLoadingSkeletons = isGeneratingOutline && remainingCount > 0; |
| 207 | |
| 208 | return ( |
| 209 | <DndContext |
| 210 | sensors={sensors} |
| 211 | collisionDetection={closestCenter} |
| 212 | onDragEnd={handleDragEnd} |
| 213 | > |
| 214 | <SortableContext |
| 215 | items={items.map((item) => item.id)} |
| 216 | strategy={verticalListSortingStrategy} |
| 217 | > |
| 218 | <div className="space-y-2"> |
| 219 | {items.map((item, index) => { |
| 220 | const selectedTemplateId = |
| 221 | outlineTemplateOverrides[item.id] ?? null; |
| 222 | |
| 223 | return ( |
| 224 | <OutlineItem |
| 225 | key={item.id} |
| 226 | id={item.id} |
| 227 | index={index + 1} |
| 228 | title={item.title} |
| 229 | onTitleChange={handleTitleChange} |
| 230 | onDelete={handleDeleteCard} |
| 231 | showLayoutControl={ |
| 232 | availableTemplates.length > 0 || selectedTemplateId !== null |
| 233 | } |
| 234 | selectedTemplateId={selectedTemplateId} |
| 235 | onTemplateChange={(templateId) => { |
| 236 | handleTemplateChange(item.id, templateId); |
| 237 | }} |
| 238 | availableTemplates={availableTemplates} |
| 239 | disabled={isGeneratingOutline} |
| 240 | onOpenTemplateModal={() => { |
| 241 | setSingleModeOutlineId(item.id); |
| 242 | setIsTemplateModalOpen(true); |
| 243 | }} |
| 244 | /> |
| 245 | ); |
| 246 | })} |
| 247 | </div> |
| 248 | </SortableContext> |
| 249 | {/* Show loading skeletons only when actually generating */} |
| 250 | {showLoadingSkeletons && |
| 251 | Array.from({ length: remainingCount }).map((_, index) => ( |
| 252 | <Skeleton key={`loading-${index}`} className="h-16 w-full" /> |
| 253 | ))} |
| 254 | </DndContext> |
| 255 | ); |
| 256 | }, [ |
| 257 | items, |
| 258 | numSlides, |
| 259 | isGeneratingOutline, |
| 260 | sensors, |
| 261 | handleDragEnd, |
| 262 | handleTitleChange, |
| 263 | handleDeleteCard, |
| 264 | outlineTemplateOverrides, |
| 265 | handleTemplateChange, |
| 266 | availableTemplates, |
| 267 | ]); |
| 268 | |
| 269 | const hideOutline = |
| 270 | webSearchEnabled && items.length === 0 && !isGeneratingOutline; |
| 271 | if (hideOutline) { |
| 272 | return null; |
| 273 | } |
| 274 | |
| 275 | return ( |
| 276 | <div className="space-y-4"> |
| 277 | <div className="flex items-center justify-between"> |
| 278 | <h2 className="text-sm text-foreground">Outline</h2> |
| 279 | <div className="flex items-center gap-2"> |
| 280 | {isGeneratingOutline && items.length > 0 && ( |
| 281 | <span className="animate-pulse text-xs text-muted-foreground"> |
| 282 | Generating… |
| 283 | </span> |
| 284 | )} |
| 285 | <Button |
| 286 | variant="ghost" |
| 287 | size="sm" |
| 288 | onClick={() => { |
| 289 | setSingleModeOutlineId(null); |
| 290 | setIsTemplateModalOpen(true); |
| 291 | }} |
| 292 | className="h-7 gap-1.5 px-2 text-xs" |
| 293 | > |
| 294 | <LayoutGrid className="size-3.5" /> |
| 295 | Layouts |
| 296 | {selectedSlideTemplates.length > 0 && ( |
| 297 | <Badge |
| 298 | variant="secondary" |
| 299 | className="ml-0.5 h-4 px-1 text-[10px]" |
| 300 | > |
| 301 | {selectedSlideTemplates.length} |
| 302 | </Badge> |
| 303 | )} |
| 304 | </Button> |
| 305 | </div> |
| 306 | </div> |
| 307 | |
| 308 | {content} |
| 309 | |
| 310 | <button |
| 311 | type="button" |
| 312 | onClick={handleAddCard} |
| 313 | disabled={isGeneratingOutline} |
| 314 | className="flex w-full items-center justify-center gap-2 rounded-md bg-muted/50 py-3 text-muted-foreground transition-colors hover:bg-muted disabled:opacity-50" |
| 315 | > |
| 316 | <Plus size={20} /> |
| 317 | Add card |
| 318 | </button> |
| 319 | |
| 320 | <div className="flex justify-between text-sm text-muted-foreground"> |
| 321 | <span>{items.length} cards total</span> |
| 322 | <span> |
| 323 | {items.reduce((acc, item) => acc + item.title.length, 0)}/20000 |
| 324 | </span> |
| 325 | </div> |
| 326 | |
| 327 | <OutlineTemplateModal |
| 328 | isOpen={isTemplateModalOpen} |
| 329 | onClose={() => { |
| 330 | setIsTemplateModalOpen(false); |
| 331 | setSingleModeOutlineId(null); |
| 332 | }} |
| 333 | mode={singleModeOutlineId ? "single" : "global"} |
| 334 | outlineId={singleModeOutlineId ?? undefined} |
| 335 | /> |
| 336 | </div> |
| 337 | ); |
| 338 | } |
| 339 |