返回 oh-my-ppt
PageSidebar.tsx
根目录 / src / renderer / src / components / session-detail / sidebar / PageSidebar.tsx
1 import { memo, useEffect, useMemo, useRef, useState } from 'react'
2 import {
3 Check,
4 Copy,
5 Download,
6 FilePlus2,
7 Files,
8 Image as ImageIcon,
9 LayoutTemplate,
10 Loader2,
11 Move,
12 PanelLeft,
13 PanelRight,
14 PencilLine,
15 Presentation,
16 Plus,
17 RotateCcw,
18 Sparkles,
19 Trash2,
20 X
21 } from 'lucide-react'
22 import { useEditHistoryStore, useEditSessionStore, useSessionDetailUiStore } from '@renderer/store'
23 import {
24 DndContext,
25 PointerSensor,
26 closestCenter,
27 useSensor,
28 useSensors,
29 type DragEndEvent
30 } from '@dnd-kit/core'
31 import {
32 SortableContext,
33 arrayMove,
34 useSortable,
35 verticalListSortingStrategy
36 } from '@dnd-kit/sortable'
37 import { CSS } from '@dnd-kit/utilities'
38 import { ScrollArea } from '../../ui/ScrollArea'
39 import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/Tooltip'
40 import {
41 AlertDialog,
42 AlertDialogAction,
43 AlertDialogCancel,
44 AlertDialogContent,
45 AlertDialogDescription,
46 AlertDialogTitle
47 } from '../../ui/AlertDialog'
48 import {
49 DropdownMenu,
50 DropdownMenuContent,
51 DropdownMenuItem,
52 DropdownMenuTrigger
53 } from '../../ui/DropdownMenu'
54 import { PageThumbnail } from './PageThumbnail'
55 import type { SessionPreviewPage } from '../shared/types'
56 import { useT } from '@renderer/i18n'
57 import { usePreviewWindow } from '../hooks/usePreviewWindow'
58 import { usePageSidebarController } from './usePageSidebarController'
59
60 const THUMBNAIL_PREVIEW_LIMIT = 8
61
62 function SortablePageItem({
63 id,
64 disabled,
65 children
66 }: {
67 id: string
68 disabled: boolean
69 children: (bindings: {
70 attributes: ReturnType<typeof useSortable>['attributes']
71 listeners: ReturnType<typeof useSortable>['listeners']
72 setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef']
73 isDragging: boolean
74 }) => React.ReactNode
75 }): React.JSX.Element {
76 const {
77 attributes,
78 listeners,
79 setNodeRef,
80 setActivatorNodeRef,
81 transform,
82 transition,
83 isDragging
84 } = useSortable({
85 id,
86 disabled
87 })
88 const style: React.CSSProperties = {
89 transform: CSS.Transform.toString(transform),
90 transition,
91 opacity: isDragging ? 0.8 : 1
92 }
93 return (
94 <div ref={setNodeRef} style={style}>
95 {children({ attributes, listeners, setActivatorNodeRef, isDragging })}
96 </div>
97 )
98 }
99
100 const getPageOutlineText = (page: SessionPreviewPage): string => {
101 return page.contentOutline?.trim() || ''
102 }
103
104 const stripOutlineHeadingPrefix = (value: string): string => {
105 return value
106 .trim()
107 .replace(/^#{1,6}\s*/, '')
108 .replace(/^(?:第?\d+|[一二三四五六七八九十]+)[\s.、::-]*/, '')
109 .trim()
110 }
111
112 const getPageOutlineDisplayText = (page: SessionPreviewPage): string => {
113 const outlineText = getPageOutlineText(page)
114 if (!outlineText) return ''
115
116 const lines = outlineText.replace(/\r\n/g, '\n').split('\n')
117 const title = stripOutlineHeadingPrefix(page.title || '')
118 const normalizedFirstLine = stripOutlineHeadingPrefix(lines[0] || '')
119
120 let firstLine = normalizedFirstLine
121 if (title && firstLine.startsWith(title)) {
122 firstLine = firstLine
123 .slice(title.length)
124 .replace(/^[\s::,,.。-]+/, '')
125 .trim()
126 }
127
128 return [firstLine, ...lines.slice(1).map((line) => line.trim())].filter(Boolean).join('\n').trim()
129 }
130
131 const copyTextToClipboard = async (text: string): Promise<void> => {
132 try {
133 await navigator.clipboard.writeText(text)
134 return
135 } catch {
136 const textarea = document.createElement('textarea')
137 textarea.value = text
138 textarea.style.position = 'fixed'
139 textarea.style.opacity = '0'
140 document.body.appendChild(textarea)
141 textarea.select()
142 try {
143 document.execCommand('copy')
144 } finally {
145 document.body.removeChild(textarea)
146 }
147 }
148 }
149
150 export const PageSidebar = memo(function PageSidebar({
151 sessionId
152 }: {
153 sessionId: string
154 }): React.JSX.Element {
155 const t = useT()
156 const {
157 pages,
158 disabled,
159 onAddBlankPage,
160 onAddPage,
161 onMergeSessionPages,
162 onMergeTemplatePages,
163 onRetryFailedPage,
164 onReorderPages,
165 onDeletePage,
166 onRenamePage,
167 onDuplicatePage,
168 onUpdatePageOutline,
169 onExportPagePptx,
170 canExportPptx,
171 onDownloadAllOutlines,
172 pageManagementDisabled,
173 isPageActionDisabled,
174 collapsed,
175 onToggleCollapsed
176 } = usePageSidebarController(sessionId)
177 const selectedPageId = useSessionDetailUiStore((state) => state.selectedPageId)
178 const previewKey = useSessionDetailUiStore((state) => state.previewKey)
179 const thumbnailVersions = useSessionDetailUiStore((state) => state.thumbnailVersions)
180 const setSelectedPageId = useSessionDetailUiStore((state) => state.setSelectedPageId)
181 const setWorkspaceTab = useSessionDetailUiStore((state) => state.setWorkspaceTab)
182 const isAddingPage = useSessionDetailUiStore((state) => state.isAddingPage)
183 const isRetryingSinglePage = useSessionDetailUiStore((state) => state.isRetryingSinglePage)
184 const retryingSinglePageId = useSessionDetailUiStore((state) => state.retryingSinglePageId)
185 const isExportingPptx = useSessionDetailUiStore((state) => state.isExportingPptx)
186 const mergeSessionPagesDialogOpen = useSessionDetailUiStore(
187 (state) => state.mergeSessionPagesDialogOpen
188 )
189 const [activeView, setActiveView] = useState<'pages' | 'outline'>('pages')
190 const [copiedOutlinePageId, setCopiedOutlinePageId] = useState<string | null>(null)
191 const [editingOutlinePageId, setEditingOutlinePageId] = useState<string | null>(null)
192 const [outlineDraft, setOutlineDraft] = useState('')
193 const [savingOutlinePageId, setSavingOutlinePageId] = useState<string | null>(null)
194 const [pendingSwitchPageId, setPendingSwitchPageId] = useState<string | null>(null)
195 const [savingBeforeSwitch, setSavingBeforeSwitch] = useState(false)
196 const wasAddingRef = useRef(false)
197 const copyResetTimerRef = useRef<number | null>(null)
198 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
199 const sortableIds = useMemo(() => pages.map((p) => p.id), [pages])
200 const selectedPage = useMemo(
201 () => pages.find((page) => page.id === selectedPageId) ?? pages[0] ?? null,
202 [pages, selectedPageId]
203 )
204 const currentPageHasPendingEdits = useEditHistoryStore((state) =>
205 state.hasPendingEdits(selectedPage?.pageId)
206 )
207 const pendingSwitchPage = useMemo(
208 () => pages.find((page) => page.id === pendingSwitchPageId) ?? null,
209 [pages, pendingSwitchPageId]
210 )
211 const {
212 activePreviewIds: thumbnailPreviewIds,
213 viewportRef,
214 schedulePreviewWindowUpdate: scheduleThumbnailPreviewWindowUpdate
215 } = usePreviewWindow({
216 enabled:
217 !collapsed && activeView === 'pages' && pages.length > 0 && !mergeSessionPagesDialogOpen,
218 itemIds: sortableIds,
219 limit: THUMBNAIL_PREVIEW_LIMIT
220 })
221
222 // Keep selected thumbnail in view when add-page completes (isAddingPage: true -> false).
223 // Fallback to bottom if selected thumbnail is not found yet.
224 useEffect(() => {
225 if (wasAddingRef.current && !isAddingPage && viewportRef.current) {
226 const viewport = viewportRef.current
227 const selectedNode = selectedPageId
228 ? viewport.querySelector<HTMLElement>(`[data-page-id="${selectedPageId}"]`)
229 : null
230
231 if (selectedNode) {
232 selectedNode.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
233 } else {
234 viewport.scrollTop = viewport.scrollHeight
235 }
236 }
237 wasAddingRef.current = isAddingPage
238 }, [isAddingPage, selectedPageId, pages.length])
239
240 useEffect(() => {
241 return () => {
242 if (copyResetTimerRef.current !== null) window.clearTimeout(copyResetTimerRef.current)
243 }
244 }, [])
245
246 const handleDragEnd = (event: DragEndEvent): void => {
247 const { active, over } = event
248 if (!over || active.id === over.id) return
249 if (!onReorderPages || pageManagementDisabled || disabled) return
250 const oldIndex = pages.findIndex((p) => p.id === String(active.id))
251 const newIndex = pages.findIndex((p) => p.id === String(over.id))
252 if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return
253 const next = arrayMove(pages, oldIndex, newIndex)
254 void onReorderPages(
255 next.map((p) => p.id),
256 String(active.id)
257 )
258 }
259
260 const requestSelectPage = (pageId: string): boolean => {
261 if (!pageId || disabled) return false
262 if (pageId === selectedPageId) {
263 setSelectedPageId(pageId)
264 setWorkspaceTab('preview')
265 return true
266 }
267 if (currentPageHasPendingEdits) {
268 setPendingSwitchPageId(pageId)
269 return false
270 }
271 setSelectedPageId(pageId)
272 setWorkspaceTab('preview')
273 return true
274 }
275
276 const handleConfirmSaveAndSwitch = async (): Promise<void> => {
277 const targetPageId = pendingSwitchPageId
278 if (!targetPageId || savingBeforeSwitch) return
279 setSavingBeforeSwitch(true)
280 try {
281 const result = await useEditSessionStore.getState().save()
282 const stillPending = useEditHistoryStore.getState().hasPendingEdits(selectedPage?.pageId)
283 if (result.saved || !stillPending) {
284 setSelectedPageId(targetPageId)
285 setWorkspaceTab('preview')
286 setPendingSwitchPageId(null)
287 }
288 } finally {
289 setSavingBeforeSwitch(false)
290 }
291 }
292
293 const handleDiscardAndSwitch = (): void => {
294 const targetPageId = pendingSwitchPageId
295 if (!targetPageId || savingBeforeSwitch) return
296 useEditSessionStore.getState().discardAll()
297 if (
298 selectedPage?.pageId &&
299 useEditHistoryStore.getState().hasPendingEdits(selectedPage.pageId)
300 ) {
301 useEditHistoryStore.getState().clearPage(selectedPage.pageId)
302 }
303 setSelectedPageId(targetPageId)
304 setWorkspaceTab('preview')
305 setPendingSwitchPageId(null)
306 }
307
308 const handleCopyOutline = async (
309 page: SessionPreviewPage,
310 outlineText: string
311 ): Promise<void> => {
312 const title = (page.title || t('sessionDetail.untitledPage')).trim()
313 const content = outlineText.trim()
314 const text = [title, content].filter(Boolean).join('\n')
315 if (!text) return
316 await copyTextToClipboard(text)
317 setCopiedOutlinePageId(page.id)
318 if (copyResetTimerRef.current !== null) window.clearTimeout(copyResetTimerRef.current)
319 copyResetTimerRef.current = window.setTimeout(() => {
320 setCopiedOutlinePageId((current) => (current === page.id ? null : current))
321 copyResetTimerRef.current = null
322 }, 1600)
323 }
324
325 const handleStartEditOutline = (page: SessionPreviewPage, outlineText: string): void => {
326 if (page.id !== selectedPageId && currentPageHasPendingEdits) {
327 setPendingSwitchPageId(page.id)
328 return
329 }
330 setSelectedPageId(page.id)
331 setEditingOutlinePageId(page.id)
332 setOutlineDraft(outlineText)
333 }
334
335 const handleCancelEditOutline = (): void => {
336 setEditingOutlinePageId(null)
337 setOutlineDraft('')
338 }
339
340 const handleSaveOutline = async (page: SessionPreviewPage): Promise<void> => {
341 if (!onUpdatePageOutline || savingOutlinePageId) return
342 const normalizedDraft = outlineDraft.replace(/\s+/g, ' ').trim()
343 if (normalizedDraft === getPageOutlineText(page)) {
344 handleCancelEditOutline()
345 return
346 }
347 setSavingOutlinePageId(page.id)
348 try {
349 await onUpdatePageOutline(page, normalizedDraft)
350 handleCancelEditOutline()
351 } finally {
352 setSavingOutlinePageId(null)
353 }
354 }
355
356 const handleDownloadAllOutlines = (): void => {
357 if (pages.length === 0) return
358 onDownloadAllOutlines?.()
359 }
360
361 return (
362 <aside
363 className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden bg-[#f5f1e8] pb-3 pt-3 shadow-[inset_-16px_0_30px_rgba(93,107,77,0.045)] transition-[width] duration-300 ${
364 collapsed ? 'w-[48px] min-w-[48px] max-w-[48px]' : 'w-[220px] min-w-[220px] max-w-[220px]'
365 }`}
366 >
367 <div className={`flex min-h-0 flex-1 flex-col ${collapsed ? 'px-1' : 'px-2.5'}`}>
368 {collapsed ? (
369 // Collapsed: compact icons
370 <>
371 {/* Middle: page list */}
372 <ScrollArea
373 className="min-h-0 min-w-0 flex-1"
374 viewportClassName="overflow-x-hidden pb-2"
375 viewportRef={viewportRef}
376 >
377 <div className="space-y-1.5">
378 {pages.map((page) => (
379 <button
380 key={page.id}
381 type="button"
382 data-page-id={page.id}
383 onClick={() => requestSelectPage(page.id)}
384 className={`flex h-8 w-full items-center justify-center rounded-xl text-xs font-semibold transition-all ${selectedPageId === page.id ? 'bg-[#d4e4c1]/86 text-[#3e4a32] shadow-[0_4px_12px_rgba(93,107,77,0.15)]' : 'text-[#5c6c47] hover:bg-[#e8e0d0]/50'}`}
385 >
386 P{page.pageNumber}
387 </button>
388 ))}
389 </div>
390 </ScrollArea>
391
392 {/* Bottom: add page + expand */}
393 <div className="mt-2 space-y-1.5">
394 <DropdownMenu>
395 <DropdownMenuTrigger asChild>
396 <button
397 type="button"
398 disabled={disabled || pageManagementDisabled}
399 title={t('sessionDetail.addPage')}
400 aria-label={t('sessionDetail.addPage')}
401 className="flex h-8 w-full items-center justify-center rounded-xl bg-[#d4e4c1]/30 text-[#5d6b4d] transition-colors hover:bg-[#d4e4c1]/50 disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
402 >
403 <Plus className="h-3.5 w-3.5" />
404 </button>
405 </DropdownMenuTrigger>
406 <DropdownMenuContent side="top" align="start" className="w-max min-w-[9rem]">
407 <DropdownMenuItem onSelect={onAddBlankPage}>
408 <FilePlus2 className="h-3.5 w-3.5 shrink-0 text-[#5f6b50]" />
409 <span className="whitespace-nowrap">{t('sessionDetail.addBlankPage')}</span>
410 </DropdownMenuItem>
411 <DropdownMenuItem onSelect={onAddPage}>
412 <Sparkles className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
413 <span className="whitespace-nowrap">{t('sessionDetail.addGeneratedPage')}</span>
414 </DropdownMenuItem>
415 <DropdownMenuItem
416 disabled={pageManagementDisabled}
417 onSelect={onMergeSessionPages}
418 >
419 <Files className="h-3.5 w-3.5 shrink-0 text-[#62758a]" />
420 <span className="whitespace-nowrap">
421 {t('sessionDetail.addPagesFromSession')}
422 </span>
423 </DropdownMenuItem>
424 <DropdownMenuItem
425 disabled={pageManagementDisabled}
426 onSelect={onMergeTemplatePages}
427 >
428 <LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
429 <span className="whitespace-nowrap">
430 {t('sessionDetail.addPagesFromTemplate')}
431 </span>
432 </DropdownMenuItem>
433 </DropdownMenuContent>
434 </DropdownMenu>
435 <Tooltip>
436 <TooltipTrigger asChild>
437 <button
438 type="button"
439 onClick={onToggleCollapsed}
440 className="flex h-8 w-full items-center justify-center rounded-xl text-[#7a875f] transition-colors hover:bg-[#e8e0d0]/50 hover:text-[#3e4a32] cursor-pointer"
441 aria-label={t('sessionDetail.expandSidebar')}
442 >
443 <PanelRight className="h-4 w-4" />
444 </button>
445 </TooltipTrigger>
446 <TooltipContent side="right">{t('sessionDetail.expandSidebar')}</TooltipContent>
447 </Tooltip>
448 </div>
449 </>
450 ) : (
451 // Expanded: full sidebar
452 <>
453 <div className="mx-1 mb-2 grid grid-cols-2 rounded-lg bg-[#e8e0d0]/38 p-0.5 text-[10.5px] font-medium text-[#6a705d]">
454 <button
455 type="button"
456 onClick={() => setActiveView('pages')}
457 className={`h-6 rounded-md transition-all ${
458 activeView === 'pages'
459 ? 'bg-[#fffaf1]/86 text-[#3e4a32] shadow-[0_1px_4px_rgba(93,107,77,0.08)]'
460 : 'hover:bg-[#fffaf1]/36 hover:text-[#4f6340]'
461 }`}
462 >
463 {t('sessionDetail.pageTab')}
464 </button>
465 <button
466 type="button"
467 onClick={() => setActiveView('outline')}
468 className={`h-6 rounded-md transition-all ${
469 activeView === 'outline'
470 ? 'bg-[#fffaf1]/86 text-[#3e4a32] shadow-[0_1px_4px_rgba(93,107,77,0.08)]'
471 : 'hover:bg-[#fffaf1]/36 hover:text-[#4f6340]'
472 }`}
473 >
474 {t('sessionDetail.outlineTab')}
475 </button>
476 </div>
477 {activeView === 'outline' ? (
478 <button
479 type="button"
480 disabled={pages.length === 0 || !onDownloadAllOutlines}
481 onClick={handleDownloadAllOutlines}
482 className="mx-1 mb-2 flex h-7 items-center justify-center gap-1.5 rounded-lg border border-[#b5c4a1]/50 bg-[#fffaf1]/72 px-2 text-[11px] font-medium text-[#5d6b4d] shadow-[0_3px_8px_rgba(86,72,53,0.05)] transition-colors hover:bg-[#d4e4c1]/45 hover:text-[#3e4a32] disabled:cursor-not-allowed disabled:opacity-45"
483 >
484 <Download className="h-3.5 w-3.5" />
485 {t('sessionDetail.downloadAllOutlines')}
486 </button>
487 ) : null}
488
489 {/* Middle: page list */}
490 <ScrollArea
491 className="min-h-0 min-w-0 flex-1"
492 viewportClassName="overflow-x-hidden px-0.5 pb-2"
493 viewportRef={viewportRef}
494 onViewportScroll={
495 activeView === 'pages' ? scheduleThumbnailPreviewWindowUpdate : undefined
496 }
497 >
498 {pages.length === 0 ? (
499 <div className="flex min-h-[96px] items-center justify-center rounded-[1.25rem] bg-[#e8e0d0]/54 text-xs text-[#8a9a7b]">
500 {t('sessionDetail.pagesEmpty')}
501 </div>
502 ) : activeView === 'outline' ? (
503 <div className="min-w-0 space-y-1.5 overflow-x-hidden">
504 {pages.map((page) => {
505 const outlineText = getPageOutlineText(page)
506 const outlineDisplayText = getPageOutlineDisplayText(page)
507 const selected = selectedPageId === page.id
508 const copied = copiedOutlinePageId === page.id
509 const editing = editingOutlinePageId === page.id
510 const savingOutline = savingOutlinePageId === page.id
511 const pageActionDisabled = disabled || isPageActionDisabled(page)
512 return (
513 <div
514 key={page.id}
515 data-page-id={page.id}
516 title={outlineText || page.title}
517 className={`group relative block w-full min-w-0 max-w-full whitespace-normal rounded-[1.25rem] p-1.5 text-left transition-all ${
518 selected
519 ? 'bg-[#d4e4c1]/86 shadow-[0_14px_26px_rgba(93,107,77,0.18)]'
520 : 'bg-[#e8e0d0]/34 hover:bg-[#e8e0d0]/68 hover:shadow-[0_8px_18px_rgba(93,107,77,0.09)]'
521 } ${pageActionDisabled ? 'opacity-45' : ''}`}
522 >
523 {editing ? (
524 <div className="block min-w-0 max-w-full overflow-hidden rounded-[1rem] bg-[#fffaf1]/72 px-2.5 py-2 shadow-[0_5px_14px_rgba(93,107,77,0.08)]">
525 <p className="mb-1 whitespace-normal break-words text-[12px] font-semibold leading-5 text-[#33402a] [overflow-wrap:anywhere]">
526 {page.title || t('sessionDetail.untitledPage')}
527 </p>
528 <textarea
529 value={outlineDraft}
530 onChange={(event) => setOutlineDraft(event.target.value)}
531 disabled={savingOutline}
532 className="min-h-[84px] w-full resize-none rounded-lg border border-[#d8cfbc]/80 bg-[#fffdf8]/90 px-2 py-1.5 text-[11px] leading-4 text-[#514736] shadow-inner outline-none focus:border-[#9bb98a] disabled:opacity-60"
533 placeholder={t('pageManagement.pageOutlinePlaceholder')}
534 autoFocus
535 />
536 <div className="mt-1.5 flex justify-end gap-1">
537 <button
538 type="button"
539 disabled={savingOutline}
540 onClick={(event) => {
541 event.stopPropagation()
542 handleCancelEditOutline()
543 }}
544 className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-[#f1e7d6] text-[#756955] transition-colors hover:bg-[#e8ddca] disabled:opacity-50"
545 aria-label={t('common.cancel')}
546 title={t('common.cancel')}
547 >
548 <X className="h-3.5 w-3.5" />
549 </button>
550 <button
551 type="button"
552 disabled={savingOutline}
553 onClick={(event) => {
554 event.stopPropagation()
555 void handleSaveOutline(page)
556 }}
557 className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-[#5d6b4d] text-white shadow-[0_4px_10px_rgba(62,74,50,0.18)] transition-colors hover:bg-[#4d5a40] disabled:opacity-50"
558 aria-label={t('pageManagement.savePageOutline')}
559 title={t('pageManagement.savePageOutline')}
560 >
561 <Check className="h-3.5 w-3.5" />
562 </button>
563 </div>
564 </div>
565 ) : (
566 <div
567 role="button"
568 tabIndex={disabled ? -1 : 0}
569 aria-disabled={disabled}
570 onClick={() => requestSelectPage(page.id)}
571 onKeyDown={(event) => {
572 if (disabled) return
573 if (event.key !== 'Enter' && event.key !== ' ') return
574 event.preventDefault()
575 requestSelectPage(page.id)
576 }}
577 className={`relative block w-full min-w-0 rounded-[1rem] text-left ${
578 disabled ? 'cursor-not-allowed' : 'cursor-pointer'
579 }`}
580 >
581 <span className="relative block min-w-0 max-w-full overflow-hidden rounded-[1rem] bg-[#fffaf1]/72 px-2.5 py-2 shadow-[0_5px_14px_rgba(93,107,77,0.08)]">
582 <span className="block whitespace-normal break-words pr-14 text-[12px] font-semibold leading-5 text-[#33402a] [overflow-wrap:anywhere]">
583 {page.title || t('sessionDetail.untitledPage')}
584 </span>
585 <span className="mt-1 block whitespace-normal break-words text-[11px] leading-4 text-[#716654] [overflow-wrap:anywhere]">
586 {outlineDisplayText || t('sessionDetail.outlineEmpty')}
587 </span>
588 {!editing ? (
589 <Tooltip>
590 <TooltipTrigger asChild>
591 <button
592 type="button"
593 disabled={pageActionDisabled}
594 onClick={(event) => {
595 event.stopPropagation()
596 handleStartEditOutline(page, outlineText)
597 }}
598 className="absolute right-9 top-2 inline-flex h-6 w-6 items-center justify-center rounded bg-white p-1 text-[#5d6b4d] opacity-0 shadow-sm transition-colors hover:bg-[#f5f1e8] hover:text-[#3e4a32] group-hover:opacity-100 focus-visible:opacity-100 disabled:cursor-not-allowed disabled:opacity-0"
599 aria-label={t('pageManagement.editPageOutline')}
600 >
601 <PencilLine className="h-3.5 w-3.5" />
602 </button>
603 </TooltipTrigger>
604 <TooltipContent side="right">
605 {t('pageManagement.editPageOutline')}
606 </TooltipContent>
607 </Tooltip>
608 ) : null}
609 <Tooltip>
610 <TooltipTrigger asChild>
611 <button
612 type="button"
613 disabled={!outlineDisplayText || editing}
614 onClick={(event) => {
615 event.stopPropagation()
616 void handleCopyOutline(page, outlineDisplayText)
617 }}
618 className={`absolute right-2 top-2 inline-flex h-6 w-6 items-center justify-center rounded-full transition-all ${
619 copied
620 ? 'bg-[#5d6b4d] text-white shadow-sm'
621 : 'bg-white text-[#5d6b4d] opacity-0 shadow-sm hover:bg-[#f5f1e8] hover:text-[#3e4a32] group-hover:opacity-100 focus-visible:opacity-100'
622 } disabled:cursor-not-allowed disabled:opacity-0`}
623 aria-label={
624 copied
625 ? t('sessionDetail.outlineCopied')
626 : t('sessionDetail.copyOutline')
627 }
628 >
629 {copied ? (
630 <Check className="h-3.5 w-3.5" />
631 ) : (
632 <Copy className="h-3.5 w-3.5" />
633 )}
634 </button>
635 </TooltipTrigger>
636 <TooltipContent side="right">
637 {copied
638 ? t('sessionDetail.outlineCopied')
639 : t('sessionDetail.copyOutline')}
640 </TooltipContent>
641 </Tooltip>
642 </span>
643 </div>
644 )}
645 <span className="relative mt-1.5 flex items-center justify-between gap-1 px-0.5">
646 <span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[#5c6c47]">
647 P{page.pageNumber}
648 </span>
649 {selected ? (
650 <span className="rounded-full bg-[#5d6b4d] px-1.5 py-0.5 text-[9px] font-semibold text-white shadow-[0_3px_8px_rgba(62,74,50,0.18)]">
651 {t('sessionDetail.current')}
652 </span>
653 ) : null}
654 </span>
655 </div>
656 )
657 })}
658 </div>
659 ) : (
660 <DndContext
661 sensors={sensors}
662 collisionDetection={closestCenter}
663 onDragEnd={handleDragEnd}
664 >
665 <SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
666 <div className="space-y-2.5">
667 {pages.map((page) => {
668 const pageActionDisabled = disabled || isPageActionDisabled(page)
669 return (
670 <div
671 key={page.id}
672 data-page-id={page.id}
673 data-preview-window-id={page.id}
674 >
675 <SortablePageItem
676 id={page.id}
677 disabled={pageManagementDisabled || disabled}
678 >
679 {({ attributes, listeners, setActivatorNodeRef, isDragging }) => (
680 <PageThumbnail
681 page={page}
682 isSelected={selectedPageId === page.id}
683 previewVersion={
684 previewKey + (thumbnailVersions[page.pageId] || 0)
685 }
686 renderPreview={thumbnailPreviewIds.has(page.id)}
687 onSelect={disabled ? undefined : requestSelectPage}
688 failureOverlay={
689 isRetryingSinglePage && retryingSinglePageId === page.id ? (
690 <div className="absolute inset-x-2 bottom-2 z-10 flex h-8 items-center justify-center gap-1.5 rounded-[0.7rem] bg-[#3e4a32]/88 px-2 text-[10px] font-semibold text-white shadow-[0_4px_12px_rgba(62,74,50,0.28)]">
691 <Loader2 className="h-3 w-3 animate-spin" />
692 {t('sessionDetail.activityRetrying')}
693 </div>
694 ) : page.status === 'failed' && onRetryFailedPage ? (
695 <button
696 type="button"
697 disabled={pageManagementDisabled || pageActionDisabled}
698 onClick={(event) => {
699 event.stopPropagation()
700 if (requestSelectPage(page.id)) onRetryFailedPage(page)
701 }}
702 className="absolute inset-x-2 bottom-2 z-10 flex h-8 items-center justify-center gap-1.5 rounded-[0.7rem] border border-red-600 bg-red-500 px-2 text-[10px] font-semibold text-white shadow-[0_4px_12px_rgba(239,68,68,0.28)] transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-45"
703 >
704 <RotateCcw className="h-3 w-3" />
705 {t('sessionDetail.retryFailedPage')}
706 </button>
707 ) : undefined
708 }
709 actions={
710 <div className="absolute inset-x-1 top-1 z-10 flex items-start justify-between opacity-0 transition-opacity group-hover:opacity-100">
711 <button
712 type="button"
713 ref={setActivatorNodeRef}
714 disabled={pageManagementDisabled || disabled}
715 onClick={(e) => {
716 e.stopPropagation()
717 }}
718 className="cursor-grab rounded bg-white/90 p-1 text-[#5d6b4d] shadow-sm transition-colors hover:bg-[#f5f1e8] hover:text-[#3e4a32] active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50"
719 aria-label={t('pageManagement.dragHandle')}
720 title={t('pageManagement.dragHandle')}
721 {...attributes}
722 {...listeners}
723 >
724 <Move
725 className={`h-4 w-4 ${isDragging ? 'opacity-60' : ''}`}
726 />
727 </button>
728 <div className="flex items-center gap-1">
729 {canExportPptx ? (
730 <DropdownMenu>
731 <Tooltip>
732 <TooltipTrigger asChild>
733 <DropdownMenuTrigger asChild>
734 <button
735 type="button"
736 disabled={pageActionDisabled || isExportingPptx}
737 onClick={(e) => e.stopPropagation()}
738 className="rounded bg-white/90 p-1 text-[#5d6b4d] shadow-sm transition-colors hover:bg-[#f5f1e8] hover:text-[#3e4a32] disabled:cursor-not-allowed disabled:opacity-50"
739 aria-label={t(
740 'sessionDetail.exportSinglePagePptx'
741 )}
742 >
743 <Presentation className="h-3.5 w-3.5" />
744 </button>
745 </DropdownMenuTrigger>
746 </TooltipTrigger>
747 <TooltipContent side="right">
748 {t('sessionDetail.exportSinglePagePptx')}
749 </TooltipContent>
750 </Tooltip>
751 <DropdownMenuContent
752 side="right"
753 align="start"
754 className="min-w-[9rem]"
755 onClick={(e) => e.stopPropagation()}
756 >
757 <DropdownMenuItem
758 onSelect={() => onExportPagePptx(page)}
759 >
760 <Presentation className="h-3.5 w-3.5 shrink-0 text-[#5f6b50]" />
761 <span className="whitespace-nowrap">
762 {t('sessionDetail.exportPptxEditable')}
763 </span>
764 </DropdownMenuItem>
765 <DropdownMenuItem
766 onSelect={() =>
767 onExportPagePptx(page, { imageOnly: true })
768 }
769 >
770 <ImageIcon className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
771 <span className="whitespace-nowrap">
772 {t('sessionDetail.exportPptxImageOnly')}
773 </span>
774 </DropdownMenuItem>
775 </DropdownMenuContent>
776 </DropdownMenu>
777 ) : null}
778 {onRenamePage ? (
779 <button
780 type="button"
781 disabled={pageActionDisabled}
782 onClick={(e) => {
783 e.stopPropagation()
784 onRenamePage(page)
785 }}
786 className="rounded bg-white/90 p-1 text-[#5d6b4d] shadow-sm transition-colors hover:bg-[#f5f1e8] hover:text-[#3e4a32] disabled:cursor-not-allowed disabled:opacity-50"
787 aria-label={t('pageManagement.editPageTitle')}
788 title={t('pageManagement.editPageTitle')}
789 >
790 <PencilLine className="h-3.5 w-3.5" />
791 </button>
792 ) : null}
793 {onDuplicatePage ? (
794 <button
795 type="button"
796 disabled={pageManagementDisabled || pageActionDisabled}
797 onClick={(e) => {
798 e.stopPropagation()
799 onDuplicatePage(page)
800 }}
801 className="rounded bg-white/90 p-1 text-[#5d6b4d] shadow-sm transition-colors hover:bg-[#f5f1e8] hover:text-[#3e4a32] disabled:cursor-not-allowed disabled:opacity-50"
802 aria-label={t('pageManagement.duplicatePage')}
803 title={t('pageManagement.duplicatePage')}
804 >
805 <Copy className="h-3.5 w-3.5" />
806 </button>
807 ) : null}
808 {onDeletePage ? (
809 <button
810 type="button"
811 disabled={
812 pageManagementDisabled ||
813 pageActionDisabled ||
814 pages.length <= 1
815 }
816 onClick={(e) => {
817 e.stopPropagation()
818 onDeletePage(page)
819 }}
820 className="rounded bg-white/90 p-1 shadow-sm disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale"
821 aria-label={t('pageManagement.deletePage')}
822 title={t('pageManagement.deletePage')}
823 >
824 <Trash2 className="h-4 w-4" />
825 </button>
826 ) : null}
827 </div>
828 </div>
829 }
830 />
831 )}
832 </SortablePageItem>
833 </div>
834 )
835 })}
836 </div>
837 </SortableContext>
838 </DndContext>
839 )}
840 </ScrollArea>
841
842 {/* Bottom: add page + collapse */}
843 <div className="mt-2 flex items-center gap-1.5">
844 <DropdownMenu>
845 <DropdownMenuTrigger asChild>
846 <button
847 type="button"
848 disabled={disabled || pageManagementDisabled}
849 className="flex flex-1 items-center justify-center gap-1 rounded-[1rem] border border-dashed border-[#b5c4a1]/60 bg-[#d4e4c1]/30 px-2 py-1.5 text-[11px] font-medium text-[#5d6b4d] transition-colors hover:bg-[#d4e4c1]/50 hover:text-[#3e4a32] disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
850 >
851 <Plus className="h-3 w-3" />
852 {t('sessionDetail.addPage')}
853 </button>
854 </DropdownMenuTrigger>
855 <DropdownMenuContent side="top" align="start" className="w-max min-w-[9rem]">
856 <DropdownMenuItem onSelect={onAddBlankPage}>
857 <FilePlus2 className="h-3.5 w-3.5 shrink-0 text-[#5f6b50]" />
858 <span className="whitespace-nowrap">{t('sessionDetail.addBlankPage')}</span>
859 </DropdownMenuItem>
860 <DropdownMenuItem onSelect={onAddPage}>
861 <Sparkles className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
862 <span className="whitespace-nowrap">{t('sessionDetail.addGeneratedPage')}</span>
863 </DropdownMenuItem>
864 <DropdownMenuItem
865 disabled={pageManagementDisabled}
866 onSelect={onMergeSessionPages}
867 >
868 <Files className="h-3.5 w-3.5 shrink-0 text-[#62758a]" />
869 <span className="whitespace-nowrap">
870 {t('sessionDetail.addPagesFromSession')}
871 </span>
872 </DropdownMenuItem>
873 <DropdownMenuItem
874 disabled={pageManagementDisabled}
875 onSelect={onMergeTemplatePages}
876 >
877 <LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
878 <span className="whitespace-nowrap">
879 {t('sessionDetail.addPagesFromTemplate')}
880 </span>
881 </DropdownMenuItem>
882 </DropdownMenuContent>
883 </DropdownMenu>
884 <Tooltip>
885 <TooltipTrigger asChild>
886 <button
887 type="button"
888 onClick={onToggleCollapsed}
889 className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[#7a875f] transition-colors hover:bg-[#e8e0d0]/60 hover:text-[#3e4a32] cursor-pointer"
890 aria-label={t('sessionDetail.collapseSidebar')}
891 >
892 <PanelLeft className="h-3.5 w-3.5" />
893 </button>
894 </TooltipTrigger>
895 <TooltipContent side="right">{t('sessionDetail.collapseSidebar')}</TooltipContent>
896 </Tooltip>
897 </div>
898 </>
899 )}
900 </div>
901 <AlertDialog
902 open={Boolean(pendingSwitchPageId)}
903 onOpenChange={(open) => {
904 if (!open && !savingBeforeSwitch) setPendingSwitchPageId(null)
905 }}
906 >
907 <AlertDialogContent>
908 <AlertDialogTitle>{t('sessionDetail.pageSwitchSaveConfirmTitle')}</AlertDialogTitle>
909 <AlertDialogDescription>
910 {t('sessionDetail.pageSwitchSaveConfirmDescription', {
911 page: pendingSwitchPage?.title || t('sessionDetail.untitledPage')
912 })}
913 </AlertDialogDescription>
914 <div className="flex justify-end gap-2">
915 <AlertDialogAction
916 disabled={savingBeforeSwitch}
917 className="border border-[#d7cbb7]/80 bg-[#fffdf8]/92 text-[#657058] hover:bg-[#f5efe4] disabled:cursor-not-allowed disabled:opacity-60"
918 onClick={(event) => {
919 event.preventDefault()
920 handleDiscardAndSwitch()
921 }}
922 >
923 {t('common.dontSave')}
924 </AlertDialogAction>
925 <AlertDialogAction
926 disabled={savingBeforeSwitch}
927 className="bg-[#5d6b4d] text-white hover:bg-[#4d5a40] disabled:cursor-not-allowed disabled:opacity-60"
928 onClick={(event) => {
929 event.preventDefault()
930 void handleConfirmSaveAndSwitch()
931 }}
932 >
933 {savingBeforeSwitch ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
934 {t('sessionDetail.pageSwitchSaveConfirmAction')}
935 </AlertDialogAction>
936 <AlertDialogCancel disabled={savingBeforeSwitch}>
937 {t('common.cancel')}
938 </AlertDialogCancel>
939 </div>
940 </AlertDialogContent>
941 </AlertDialog>
942 </aside>
943 )
944 })
945
945 lines Plain Text