| 1 | import { useSortable } from "@dnd-kit/sortable"; |
| 2 | import { CSS } from "@dnd-kit/utilities"; |
| 3 | import { Check, ChevronDown, GripVertical, Plus, X } from "lucide-react"; |
| 4 | import { memo, useState } from "react"; |
| 5 | |
| 6 | import ProseMirrorEditor from "@/components/prose-mirror/ProseMirrorEditor"; |
| 7 | import { |
| 8 | Popover, |
| 9 | PopoverContent, |
| 10 | PopoverTrigger, |
| 11 | } from "@/components/ui/popover"; |
| 12 | import { cn } from "@/lib/utils"; |
| 13 | import { |
| 14 | getTemplateSelectionIds, |
| 15 | TEMPLATE_DEFINITIONS, |
| 16 | } from "../../utils/templates"; |
| 17 | |
| 18 | interface OutlineItemProps { |
| 19 | id: string; |
| 20 | index: number; |
| 21 | title: string; |
| 22 | onTitleChange: (id: string, newTitle: string) => void; |
| 23 | onDelete: (id: string) => void; |
| 24 | // Template selection props |
| 25 | showLayoutControl?: boolean; |
| 26 | selectedTemplateId?: string | null; |
| 27 | onTemplateChange?: (templateId: string | null) => void; |
| 28 | availableTemplates?: Array<{ id: string; name: string }>; |
| 29 | onOpenTemplateModal?: () => void; |
| 30 | disabled?: boolean; |
| 31 | } |
| 32 | |
| 33 | // Wrap the component with memo to prevent unnecessary re-renders |
| 34 | export const OutlineItem = memo(function OutlineItem({ |
| 35 | id, |
| 36 | index, |
| 37 | title, |
| 38 | onTitleChange, |
| 39 | onDelete, |
| 40 | showLayoutControl = false, |
| 41 | selectedTemplateId = null, |
| 42 | onTemplateChange, |
| 43 | availableTemplates = [], |
| 44 | onOpenTemplateModal, |
| 45 | disabled = false, |
| 46 | }: OutlineItemProps) { |
| 47 | const [isDropdownOpen, setIsDropdownOpen] = useState(false); |
| 48 | |
| 49 | const { |
| 50 | attributes, |
| 51 | listeners, |
| 52 | setNodeRef, |
| 53 | transform, |
| 54 | transition, |
| 55 | isDragging, |
| 56 | } = useSortable({ id }); |
| 57 | |
| 58 | const style = { |
| 59 | transform: CSS.Translate.toString(transform), |
| 60 | transition, |
| 61 | }; |
| 62 | |
| 63 | const handleProseMirrorChange = (newContent: string) => { |
| 64 | onTitleChange(id, newContent); |
| 65 | }; |
| 66 | |
| 67 | const handleTemplateSelect = (templateId: string | null) => { |
| 68 | onTemplateChange?.(templateId); |
| 69 | setIsDropdownOpen(false); |
| 70 | }; |
| 71 | |
| 72 | const handleMoreClick = () => { |
| 73 | setIsDropdownOpen(false); |
| 74 | onOpenTemplateModal?.(); |
| 75 | }; |
| 76 | |
| 77 | // Get display name for selected template |
| 78 | const selectedTemplateName = selectedTemplateId |
| 79 | ? (availableTemplates.find((t) => t.id === selectedTemplateId)?.name ?? |
| 80 | TEMPLATE_DEFINITIONS.find((template) => |
| 81 | getTemplateSelectionIds(template).includes(selectedTemplateId), |
| 82 | )?.name ?? |
| 83 | "Custom") |
| 84 | : "Auto"; |
| 85 | |
| 86 | return ( |
| 87 | <div |
| 88 | ref={setNodeRef} |
| 89 | style={style} |
| 90 | className={cn( |
| 91 | "group flex items-center gap-4 rounded-md bg-muted p-4", |
| 92 | isDragging && "opacity-50", |
| 93 | )} |
| 94 | > |
| 95 | <div |
| 96 | {...attributes} |
| 97 | {...listeners} |
| 98 | className="cursor-move text-muted-foreground hover:text-foreground" |
| 99 | > |
| 100 | <GripVertical size={20} /> |
| 101 | </div> |
| 102 | <span className="min-w-6 text-primary">{index}</span> |
| 103 | <div className="flex-1"> |
| 104 | <ProseMirrorEditor |
| 105 | content={title} |
| 106 | onChange={handleProseMirrorChange} |
| 107 | isEditing={!disabled} |
| 108 | className="prose-headings:m-0 prose-headings:text-lg prose-headings:font-semibold prose-p:m-0 prose-ol:m-0 prose-ul:m-0 prose-li:m-0" |
| 109 | showFloatingToolbar={!disabled} |
| 110 | /> |
| 111 | </div> |
| 112 | {showLayoutControl && ( |
| 113 | <Popover open={isDropdownOpen} onOpenChange={setIsDropdownOpen}> |
| 114 | <PopoverTrigger asChild> |
| 115 | <button |
| 116 | type="button" |
| 117 | disabled={disabled} |
| 118 | className={cn( |
| 119 | "flex h-8 items-center gap-1.5 rounded-md border border-border/50 bg-background/50 px-3 text-xs font-medium text-primary transition-colors", |
| 120 | "hover:border-border hover:bg-background/80 hover:text-foreground", |
| 121 | isDropdownOpen && "border-border bg-background/80 text-primary", |
| 122 | disabled && "pointer-events-none opacity-50", |
| 123 | )} |
| 124 | > |
| 125 | <span className="max-w-20 truncate">{selectedTemplateName}</span> |
| 126 | <ChevronDown className="size-3.5 opacity-70" /> |
| 127 | </button> |
| 128 | </PopoverTrigger> |
| 129 | <PopoverContent |
| 130 | align="end" |
| 131 | sideOffset={4} |
| 132 | className="w-80 overflow-hidden p-1.5" |
| 133 | > |
| 134 | <div className="scrollbar-thin max-h-96 space-y-0.5 overflow-y-auto overscroll-contain pr-1 scrollbar-thumb-muted scrollbar-track-transparent"> |
| 135 | {/* Auto option */} |
| 136 | <button |
| 137 | type="button" |
| 138 | onClick={() => handleTemplateSelect(null)} |
| 139 | className={cn( |
| 140 | "flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent", |
| 141 | !selectedTemplateId && "bg-accent", |
| 142 | )} |
| 143 | > |
| 144 | <div className="flex h-24 w-28 shrink-0 items-center justify-center overflow-hidden rounded border border-border bg-card"> |
| 145 | <span className="text-[10px] text-muted-foreground"> |
| 146 | Auto |
| 147 | </span> |
| 148 | </div> |
| 149 | <span className="flex-1 text-sm">Auto (AI)</span> |
| 150 | {!selectedTemplateId && ( |
| 151 | <Check className="size-4 shrink-0 text-primary" /> |
| 152 | )} |
| 153 | </button> |
| 154 | |
| 155 | {/* Template options with previews */} |
| 156 | {availableTemplates.map((template) => { |
| 157 | const templateDef = TEMPLATE_DEFINITIONS.find( |
| 158 | (t) => t.id === template.id, |
| 159 | ); |
| 160 | const isSelected = templateDef |
| 161 | ? getTemplateSelectionIds(templateDef).includes( |
| 162 | selectedTemplateId ?? "", |
| 163 | ) |
| 164 | : selectedTemplateId === template.id; |
| 165 | return ( |
| 166 | <button |
| 167 | key={template.id} |
| 168 | type="button" |
| 169 | onClick={() => handleTemplateSelect(template.id)} |
| 170 | className={cn( |
| 171 | "flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent", |
| 172 | isSelected && "bg-accent", |
| 173 | )} |
| 174 | > |
| 175 | <div className="h-24 w-28 shrink-0 overflow-hidden rounded border border-border bg-card"> |
| 176 | {templateDef?.preview} |
| 177 | </div> |
| 178 | <span className="flex-1 truncate text-sm"> |
| 179 | {template.name} |
| 180 | </span> |
| 181 | {isSelected && ( |
| 182 | <Check className="size-4 shrink-0 text-primary" /> |
| 183 | )} |
| 184 | </button> |
| 185 | ); |
| 186 | })} |
| 187 | |
| 188 | {/* More option */} |
| 189 | <button |
| 190 | type="button" |
| 191 | onClick={handleMoreClick} |
| 192 | className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left text-primary transition-colors hover:bg-accent" |
| 193 | > |
| 194 | <div className="flex h-24 w-28 shrink-0 items-center justify-center overflow-hidden rounded border border-dashed border-primary/50 bg-primary/5"> |
| 195 | <Plus className="size-4 text-primary" /> |
| 196 | </div> |
| 197 | <span className="flex-1 text-sm">More Layouts</span> |
| 198 | </button> |
| 199 | </div> |
| 200 | </PopoverContent> |
| 201 | </Popover> |
| 202 | )} |
| 203 | <button |
| 204 | type="button" |
| 205 | onClick={() => onDelete(id)} |
| 206 | disabled={disabled} |
| 207 | className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-400 disabled:pointer-events-none disabled:opacity-30" |
| 208 | > |
| 209 | <X size={20} /> |
| 210 | </button> |
| 211 | </div> |
| 212 | ); |
| 213 | }); |
| 214 | |
| 215 | // Add a display name for debugging purposes |
| 216 | OutlineItem.displayName = "OutlineItem"; |
| 217 |