返回 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 <Tooltip>
712 <TooltipTrigger asChild>
713 <button
714 type="button"
715 ref={setActivatorNodeRef}
716 disabled={pageManagementDisabled || disabled}
717 onClick={(e) => {
718 e.stopPropagation()
719 }}
720 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"
721 aria-label={t('pageManagement.dragHandle')}
722 {...attributes}
723 {...listeners}
724 >
725 <Move
726 className={`h-4 w-4 ${isDragging ? 'opacity-60' : ''}`}
727 />
728 </button>
729 </TooltipTrigger>
730 <TooltipContent side="bottom" align="start">
731 {t('pageManagement.dragHandle')}
732 </TooltipContent>
733 </Tooltip>
734 <div className="flex items-center gap-1">
735 {canExportPptx ? (
736 <DropdownMenu>
737 <Tooltip>
738 <TooltipTrigger asChild>
739 <DropdownMenuTrigger asChild>
740 <button
741 type="button"
742 disabled={pageActionDisabled || isExportingPptx}
743 onClick={(e) => e.stopPropagation()}
744 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"
745 aria-label={t(
746 'sessionDetail.exportSinglePagePptx'
747 )}
748 >
749 <Presentation className="h-3.5 w-3.5" />
750 </button>
751 </DropdownMenuTrigger>
752 </TooltipTrigger>
753 <TooltipContent side="right">
754 {t('sessionDetail.exportSinglePagePptx')}
755 </TooltipContent>
756 </Tooltip>
757 <DropdownMenuContent
758 side="right"
759 align="start"
760 className="min-w-[9rem]"
761 onClick={(e) => e.stopPropagation()}
762 >
763 <DropdownMenuItem
764 onSelect={() => onExportPagePptx(page)}
765 >
766 <Presentation className="h-3.5 w-3.5 shrink-0 text-[#5f6b50]" />
767 <span className="whitespace-nowrap">
768 {t('sessionDetail.exportPptxEditable')}
769 </span>
770 </DropdownMenuItem>
771 <DropdownMenuItem
772 onSelect={() =>
773 onExportPagePptx(page, { imageOnly: true })
774 }
775 >
776 <ImageIcon className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
777 <span className="whitespace-nowrap">
778 {t('sessionDetail.exportPptxImageOnly')}
779 </span>
780 </DropdownMenuItem>
781 </DropdownMenuContent>
782 </DropdownMenu>
783 ) : null}
784 {onRenamePage ? (
785 <Tooltip>
786 <TooltipTrigger asChild>
787 <button
788 type="button"
789 disabled={pageActionDisabled}
790 onClick={(e) => {
791 e.stopPropagation()
792 onRenamePage(page)
793 }}
794 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"
795 aria-label={t('pageManagement.editPageTitle')}
796 >
797 <PencilLine className="h-3.5 w-3.5" />
798 </button>
799 </TooltipTrigger>
800 <TooltipContent side="bottom">
801 {t('pageManagement.editPageTitle')}
802 </TooltipContent>
803 </Tooltip>
804 ) : null}
805 {onDuplicatePage ? (
806 <Tooltip>
807 <TooltipTrigger asChild>
808 <button
809 type="button"
810 disabled={pageManagementDisabled || pageActionDisabled}
811 onClick={(e) => {
812 e.stopPropagation()
813 onDuplicatePage(page)
814 }}
815 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"
816 aria-label={t('pageManagement.duplicatePage')}
817 >
818 <Copy className="h-3.5 w-3.5" />
819 </button>
820 </TooltipTrigger>
821 <TooltipContent side="bottom">
822 {t('pageManagement.duplicatePage')}
823 </TooltipContent>
824 </Tooltip>
825 ) : null}
826 {onDeletePage ? (
827 <Tooltip>
828 <TooltipTrigger asChild>
829 <button
830 type="button"
831 disabled={
832 pageManagementDisabled ||
833 pageActionDisabled ||
834 pages.length <= 1
835 }
836 onClick={(e) => {
837 e.stopPropagation()
838 onDeletePage(page)
839 }}
840 className="rounded bg-white/90 p-1 shadow-sm disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale"
841 aria-label={t('pageManagement.deletePage')}
842 >
843 <Trash2 className="h-4 w-4" />
844 </button>
845 </TooltipTrigger>
846 <TooltipContent side="bottom" align="end">
847 {t('pageManagement.deletePage')}
848 </TooltipContent>
849 </Tooltip>
850 ) : null}
851 </div>
852 </div>
853 }
854 />
855 )}
856 </SortablePageItem>
857 </div>
858 )
859 })}
860 </div>
861 </SortableContext>
862 </DndContext>
863 )}
864 </ScrollArea>
865
866 {/* Bottom: add page + collapse */}
867 <div className="mt-2 flex items-center gap-1.5">
868 <DropdownMenu>
869 <DropdownMenuTrigger asChild>
870 <button
871 type="button"
872 disabled={disabled || pageManagementDisabled}
873 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"
874 >
875 <Plus className="h-3 w-3" />
876 {t('sessionDetail.addPage')}
877 </button>
878 </DropdownMenuTrigger>
879 <DropdownMenuContent side="top" align="start" className="w-max min-w-[9rem]">
880 <DropdownMenuItem onSelect={onAddBlankPage}>
881 <FilePlus2 className="h-3.5 w-3.5 shrink-0 text-[#5f6b50]" />
882 <span className="whitespace-nowrap">{t('sessionDetail.addBlankPage')}</span>
883 </DropdownMenuItem>
884 <DropdownMenuItem onSelect={onAddPage}>
885 <Sparkles className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
886 <span className="whitespace-nowrap">{t('sessionDetail.addGeneratedPage')}</span>
887 </DropdownMenuItem>
888 <DropdownMenuItem
889 disabled={pageManagementDisabled}
890 onSelect={onMergeSessionPages}
891 >
892 <Files className="h-3.5 w-3.5 shrink-0 text-[#62758a]" />
893 <span className="whitespace-nowrap">
894 {t('sessionDetail.addPagesFromSession')}
895 </span>
896 </DropdownMenuItem>
897 <DropdownMenuItem
898 disabled={pageManagementDisabled}
899 onSelect={onMergeTemplatePages}
900 >
901 <LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
902 <span className="whitespace-nowrap">
903 {t('sessionDetail.addPagesFromTemplate')}
904 </span>
905 </DropdownMenuItem>
906 </DropdownMenuContent>
907 </DropdownMenu>
908 <Tooltip>
909 <TooltipTrigger asChild>
910 <button
911 type="button"
912 onClick={onToggleCollapsed}
913 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"
914 aria-label={t('sessionDetail.collapseSidebar')}
915 >
916 <PanelLeft className="h-3.5 w-3.5" />
917 </button>
918 </TooltipTrigger>
919 <TooltipContent side="right">{t('sessionDetail.collapseSidebar')}</TooltipContent>
920 </Tooltip>
921 </div>
922 </>
923 )}
924 </div>
925 <AlertDialog
926 open={Boolean(pendingSwitchPageId)}
927 onOpenChange={(open) => {
928 if (!open && !savingBeforeSwitch) setPendingSwitchPageId(null)
929 }}
930 >
931 <AlertDialogContent>
932 <AlertDialogTitle>{t('sessionDetail.pageSwitchSaveConfirmTitle')}</AlertDialogTitle>
933 <AlertDialogDescription>
934 {t('sessionDetail.pageSwitchSaveConfirmDescription', {
935 page: pendingSwitchPage?.title || t('sessionDetail.untitledPage')
936 })}
937 </AlertDialogDescription>
938 <div className="flex justify-end gap-2">
939 <AlertDialogAction
940 disabled={savingBeforeSwitch}
941 className="border border-[#d7cbb7]/80 bg-[#fffdf8]/92 text-[#657058] hover:bg-[#f5efe4] disabled:cursor-not-allowed disabled:opacity-60"
942 onClick={(event) => {
943 event.preventDefault()
944 handleDiscardAndSwitch()
945 }}
946 >
947 {t('common.dontSave')}
948 </AlertDialogAction>
949 <AlertDialogAction
950 disabled={savingBeforeSwitch}
951 className="bg-[#5d6b4d] text-white hover:bg-[#4d5a40] disabled:cursor-not-allowed disabled:opacity-60"
952 onClick={(event) => {
953 event.preventDefault()
954 void handleConfirmSaveAndSwitch()
955 }}
956 >
957 {savingBeforeSwitch ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
958 {t('sessionDetail.pageSwitchSaveConfirmAction')}
959 </AlertDialogAction>
960 <AlertDialogCancel disabled={savingBeforeSwitch}>
961 {t('common.cancel')}
962 </AlertDialogCancel>
963 </div>
964 </AlertDialogContent>
965 </AlertDialog>
966 </aside>
967 )
968 })
969
969 lines Plain Text