返回 oh-my-ppt
sessions.tsx
根目录 / src / renderer / src / pages / sessions.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2 import { useNavigate } from 'react-router-dom'
3 import { Button } from '../components/ui/Button'
4 import { Card, CardContent, CardTitle } from '../components/ui/Card'
5 import { Input } from '../components/ui/Input'
6 import {
7 Dialog,
8 DialogContent,
9 DialogDescription,
10 DialogFooter,
11 DialogHeader,
12 DialogTitle
13 } from '../components/ui/Dialog'
14 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../components/ui/Tooltip'
15 import {
16 FileArchive,
17 FileText,
18 FileUp,
19 FolderOpen,
20 LayoutTemplate,
21 MessageSquare,
22 MessagesSquare,
23 Pencil,
24 Search,
25 Sparkles,
26 Trash2,
27 X,
28 type LucideIcon
29 } from 'lucide-react'
30 import { type Session, useSessionStore, useTemplateStore } from '../store'
31 import { useToastStore } from '../store'
32 import { ipc, type GenerateRunStateSnapshot, type HtmlThumbnailTask } from '../lib/ipc'
33 import { getEditorGate, parseSessionMetadata } from '../lib/sessionMetadata'
34 import { useT } from '../i18n'
35 import { SaveTemplateDialog } from '../components/templates/SaveTemplateDialog'
36 import { useThumbnailUpdates } from '../hooks/useThumbnailUpdates'
37 import sessionPlaceholder from '../assets/images/space.webp'
38 import dayjs from 'dayjs'
39 import duration from 'dayjs/plugin/duration'
40 import { localAssetUrl } from '@shared/local-asset'
41 import { resolveSessionSlideSize } from '@shared/slide-size'
42
43 dayjs.extend(duration)
44
45 type ActiveGenerateRun = GenerateRunStateSnapshot & {
46 status: 'queued' | 'running'
47 }
48
49 const sessionThumbnailUrl = (filePath: string): string =>
50 import.meta.env.MODE === 'test' ? 'about:blank' : localAssetUrl(filePath)
51
52 const getSourceTag = (
53 session: Session,
54 labels: {
55 pptx: string
56 sessionFile: string
57 saveAsNew: string
58 document: string
59 ai: string
60 thinking: string
61 template: string
62 }
63 ): { label: string; Icon: LucideIcon; className: string; iconClassName: string } => {
64 const metadata = parseSessionMetadata(session.metadata)
65 const source = typeof metadata.source === 'string' ? metadata.source : ''
66 if (source === 'session-save-as-new') {
67 return {
68 label: labels.saveAsNew,
69 Icon: FileArchive,
70 className:
71 'border-[#6fc2aa]/50 bg-[#e8f8f3] text-[#1f6f5f] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)]',
72 iconClassName: 'text-[#189072]'
73 }
74 }
75 if (
76 source === 'template' ||
77 source === 'template-direct-edit' ||
78 session.model === 'template-direct-edit'
79 ) {
80 return {
81 label: labels.template,
82 Icon: LayoutTemplate,
83 className:
84 'border-[#e48aa5]/50 bg-[#fff0f5] text-[#8b3352] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)]',
85 iconClassName: 'text-[#c94672]'
86 }
87 }
88 if (source === 'session-file-import' || session.model === 'session-file-import') {
89 return {
90 label: labels.sessionFile,
91 Icon: FileArchive,
92 className:
93 'border-[#a798ee]/55 bg-[#f3f0ff] text-[#5642a2] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)]',
94 iconClassName: 'text-[#765ee0]'
95 }
96 }
97 if (
98 source === 'pptx-import' ||
99 session.provider === 'import' ||
100 session.model === 'pptx-import'
101 ) {
102 return {
103 label: labels.pptx,
104 Icon: FileUp,
105 className:
106 'border-[#74b6e5]/55 bg-[#eef8ff] text-[#286a9a] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)]',
107 iconClassName: 'text-[#2582c3]'
108 }
109 }
110 if (source === 'thinking') {
111 return {
112 label: labels.thinking,
113 Icon: MessagesSquare,
114 className:
115 'border-[#82c86a]/55 bg-[#effbe9] text-[#38702c] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)]',
116 iconClassName: 'text-[#4a9e39]'
117 }
118 }
119 if (session.referenceDocumentPath || session.reference_document_path) {
120 return {
121 label: labels.document,
122 Icon: FileText,
123 className:
124 'border-[#d0b157]/55 bg-[#fff8df] text-[#7b5d13] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)]',
125 iconClassName: 'text-[#ad7d10]'
126 }
127 }
128 return {
129 label: labels.ai,
130 Icon: Sparkles,
131 className:
132 'border-[#f0a96b]/55 bg-[#fff3e6] text-[#8a5425] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)]',
133 iconClassName: 'text-[#d87721]'
134 }
135 }
136
137 export function SessionsPage(): React.JSX.Element {
138 const navigate = useNavigate()
139 const { sessions, fetchSessions, deleteSession, updateSessionTitle, importSessionFile } =
140 useSessionStore()
141 const { createTemplateFromSession } = useTemplateStore()
142 const { success, error } = useToastStore()
143 const t = useT()
144 const [renameSession, setRenameSession] = useState<Session | null>(null)
145 const [renameTitle, setRenameTitle] = useState('')
146 const [renaming, setRenaming] = useState(false)
147 const [deleteSessionTarget, setDeleteSessionTarget] = useState<Session | null>(null)
148 const [deleting, setDeleting] = useState(false)
149 const [importingSession, setImportingSession] = useState(false)
150 const [saveTemplateTarget, setSaveTemplateTarget] = useState<Session | null>(null)
151 const [savingTemplate, setSavingTemplate] = useState(false)
152 const [activeRuns, setActiveRuns] = useState<Record<string, ActiveGenerateRun>>({})
153 const [thumbnailPaths, setThumbnailPaths] = useState<Record<string, string>>({})
154 const [searchQuery, setSearchQuery] = useState('')
155 const [searchOpen, setSearchOpen] = useState(false)
156 const searchInputRef = useRef<HTMLInputElement | null>(null)
157
158 useEffect(() => {
159 void fetchSessions()
160 }, [fetchSessions])
161
162 useEffect(() => {
163 if (!searchOpen) return
164 searchInputRef.current?.focus()
165 }, [searchOpen])
166
167 useEffect(() => {
168 let mounted = true
169 void Promise.all([
170 ipc.listActiveGenerateRuns(),
171 ipc.listActivePageEditRuns(),
172 ipc.listActiveDeckEditRuns()
173 ])
174 .then(([generationRuns, pageEditRuns, deckEditRuns]) => {
175 if (!mounted) return
176 setActiveRuns(
177 Object.fromEntries(
178 [...generationRuns, ...pageEditRuns, ...deckEditRuns]
179 .filter(
180 (run): run is ActiveGenerateRun =>
181 run.status === 'queued' || run.status === 'running'
182 )
183 .map((run) => [run.sessionId, run])
184 )
185 )
186 })
187 .catch(() => {})
188
189 const unsubscribe = ipc.onGenerateChunk((chunk) => {
190 const sessionId = chunk.payload.sessionId
191 if (!sessionId) return
192 if (chunk.type === 'run_completed' || chunk.type === 'run_error') {
193 void fetchSessions()
194 }
195 setActiveRuns((prev) => {
196 if (chunk.type === 'run_completed' || chunk.type === 'run_error') {
197 const previous = prev[sessionId]
198 if (!previous || previous.runId !== chunk.payload.runId) return prev
199 const next = { ...prev }
200 delete next[sessionId]
201 return next
202 }
203 const previous = prev[sessionId]
204 if (previous?.runId && previous.runId !== chunk.payload.runId) return prev
205 const stage = 'stage' in chunk.payload ? chunk.payload.stage : ''
206 const completedPageCount =
207 'completedPageCount' in chunk.payload &&
208 typeof chunk.payload.completedPageCount === 'number'
209 ? chunk.payload.completedPageCount
210 : previous?.completedPageCount || 0
211 const failedPageCount =
212 'failedPageCount' in chunk.payload && typeof chunk.payload.failedPageCount === 'number'
213 ? chunk.payload.failedPageCount
214 : previous?.failedPageCount || 0
215 return {
216 ...prev,
217 [sessionId]: {
218 sessionId,
219 runId: chunk.payload.runId,
220 status: stage === 'queued' ? 'queued' : 'running',
221 hasActiveRun: true,
222 progress:
223 'progress' in chunk.payload && typeof chunk.payload.progress === 'number'
224 ? Math.max(0, Math.min(90, Math.floor(chunk.payload.progress)))
225 : previous?.progress || 0,
226 totalPages:
227 'totalPages' in chunk.payload && typeof chunk.payload.totalPages === 'number'
228 ? Math.max(1, Math.floor(chunk.payload.totalPages))
229 : previous?.totalPages || 1,
230 events: previous?.events || [],
231 error: null,
232 startedAt: previous?.startedAt || Date.now(),
233 updatedAt: Date.now(),
234 kind:
235 chunk.payload.activityKind === 'page-edit'
236 ? 'page-edit'
237 : chunk.payload.activityKind === 'deck-edit'
238 ? 'deck-edit'
239 : chunk.payload.activityKind === 'addPage'
240 ? 'add-page'
241 : chunk.payload.activityKind === 'single-page-retry'
242 ? 'single-page-retry'
243 : previous?.kind,
244 completedPageCount,
245 failedPageCount
246 }
247 }
248 })
249 })
250
251 return () => {
252 mounted = false
253 unsubscribe?.()
254 }
255 }, [fetchSessions])
256
257 const sortedSessions = sessions
258 const filteredSessions = useMemo(() => {
259 const query = searchQuery.trim().toLocaleLowerCase()
260 if (!query) return sortedSessions
261 return sortedSessions.filter((session) => session.title.toLocaleLowerCase().includes(query))
262 }, [searchQuery, sortedSessions])
263 const applyThumbnail = useCallback((task: HtmlThumbnailTask): void => {
264 if (task.variant !== 'first-page' || !task.thumbnailPath) return
265 setThumbnailPaths((current) => ({ ...current, [task.resourceId]: task.thumbnailPath! }))
266 }, [])
267
268 useThumbnailUpdates('session', applyThumbnail)
269 const canEnterEditor = (session: {
270 id: string
271 status: string
272 metadata: string | null
273 page_count: number | null
274 }): boolean => getEditorGate(session, 0.68).canEdit
275
276 const getSessionRoute = (session: {
277 id: string
278 status: string
279 metadata: string | null
280 page_count: number | null
281 }): string => {
282 const activeRun = activeRuns[session.id]
283 const metadata = parseSessionMetadata(session.metadata)
284 if (activeRun) {
285 if (
286 (activeRun.kind === 'edit' ||
287 activeRun.kind === 'page-edit' ||
288 activeRun.kind === 'deck-edit' ||
289 activeRun.kind === 'add-page' ||
290 activeRun.kind === 'single-page-retry') &&
291 canEnterEditor(session)
292 ) {
293 return `/sessions/${session.id}`
294 }
295 return activeRun.kind === 'template' || metadata.source === 'template'
296 ? `/sessions/${session.id}/template-generating`
297 : `/sessions/${session.id}/generating`
298 }
299 if (canEnterEditor(session)) return `/sessions/${session.id}`
300 return metadata.source === 'template'
301 ? `/sessions/${session.id}/template-generating`
302 : `/sessions/${session.id}/generating`
303 }
304
305 const openRenameDialog = (session: Session): void => {
306 setRenameSession(session)
307 setRenameTitle(session.title)
308 }
309
310 const closeRenameDialog = (): void => {
311 if (renaming) return
312 setRenameSession(null)
313 setRenameTitle('')
314 }
315
316 const handleRenameSubmit = async (): Promise<void> => {
317 if (!renameSession) return
318 const title = renameTitle.trim()
319 if (!title) {
320 error(t('sessions.titleEmpty'))
321 return
322 }
323 if (title.length > 120) {
324 error(t('sessions.titleTooLong'), { description: t('sessions.titleTooLongDescription') })
325 return
326 }
327 setRenaming(true)
328 try {
329 await updateSessionTitle({ sessionId: renameSession.id, title })
330 success(t('sessions.titleUpdated'))
331 setRenameSession(null)
332 setRenameTitle('')
333 } catch (err) {
334 error(t('sessions.renameFailed'), {
335 description: err instanceof Error ? err.message : t('common.retryLater')
336 })
337 } finally {
338 setRenaming(false)
339 }
340 }
341
342 const closeDeleteDialog = (): void => {
343 if (deleting) return
344 setDeleteSessionTarget(null)
345 }
346
347 const handleDeleteSession = async (): Promise<void> => {
348 if (!deleteSessionTarget) return
349 setDeleting(true)
350 try {
351 await deleteSession(deleteSessionTarget.id)
352 success(t('sessions.deleted'))
353 setDeleteSessionTarget(null)
354 } catch (err) {
355 error(t('sessions.deleteFailed'), {
356 description: err instanceof Error ? err.message : t('common.retryLater')
357 })
358 } finally {
359 setDeleting(false)
360 }
361 }
362
363 const handleImportSessionFile = async (): Promise<void> => {
364 setImportingSession(true)
365 try {
366 const result = await importSessionFile()
367 if (result.cancelled) return
368 success(t('sessions.importDone'), {
369 description: t('sessions.importedDescription', {
370 title: result.title || t('sessions.importedFallbackTitle'),
371 pageCount: result.pageCount || 0
372 })
373 })
374 } catch (err) {
375 error(t('sessions.importFailed'), {
376 description: err instanceof Error ? err.message : t('common.retryLater')
377 })
378 } finally {
379 setImportingSession(false)
380 }
381 }
382
383 const handleSaveTemplate = async (payload: {
384 name: string
385 description: string
386 tags: string[]
387 }): Promise<void> => {
388 if (!saveTemplateTarget || savingTemplate) return
389 setSavingTemplate(true)
390 try {
391 await createTemplateFromSession({
392 sessionId: saveTemplateTarget.id,
393 ...payload
394 })
395 success(t('sessionDetail.templateSaved'), {
396 action: {
397 label: t('sessionDetail.viewTemplates'),
398 onClick: () => navigate('/templates')
399 }
400 })
401 setSaveTemplateTarget(null)
402 } catch (err) {
403 error(t('sessionDetail.templateSaveFailed'), {
404 description: err instanceof Error ? err.message : t('common.retryLater')
405 })
406 } finally {
407 setSavingTemplate(false)
408 }
409 }
410
411 return (
412 <div className="mx-auto w-full max-w-6xl p-6">
413 <div className="mb-6">
414 <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
415 {t('sessions.eyebrow')}
416 </p>
417 <div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
418 <div className="min-w-0">
419 <h1 className="organic-serif text-[32px] font-semibold leading-none text-[#3e4a32]">
420 {t('sessions.title')}
421 </h1>
422 </div>
423 <div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
424 {sessions.length > 0 ? (
425 searchOpen || searchQuery ? (
426 <div className="relative w-full sm:w-64">
427 <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#829071]" />
428 <Input
429 ref={searchInputRef}
430 type="search"
431 value={searchQuery}
432 placeholder={t('sessions.searchPlaceholder')}
433 className="h-9 bg-[#fffaf1] pl-9 pr-10"
434 onChange={(event) => setSearchQuery(event.target.value)}
435 onBlur={() => {
436 if (!searchQuery.trim()) setSearchOpen(false)
437 }}
438 />
439 <Button
440 type="button"
441 variant="ghost"
442 size="sm"
443 aria-label={t('sessions.clearSearch')}
444 className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0 text-[#829071] hover:text-[#3e4a32]"
445 onMouseDown={(event) => event.preventDefault()}
446 onClick={() => {
447 setSearchQuery('')
448 setSearchOpen(false)
449 }}
450 >
451 <X className="h-4 w-4" />
452 </Button>
453 </div>
454 ) : (
455 <TooltipProvider delayDuration={180}>
456 <Tooltip>
457 <TooltipTrigger asChild>
458 <Button
459 size="sm"
460 variant="outline"
461 aria-label={t('sessions.searchButton')}
462 onClick={() => setSearchOpen(true)}
463 >
464 <Search className="h-4 w-4" />
465 </Button>
466 </TooltipTrigger>
467 <TooltipContent side="bottom" align="end">
468 {t('sessions.searchButton')}
469 </TooltipContent>
470 </Tooltip>
471 </TooltipProvider>
472 )
473 ) : null}
474 <TooltipProvider delayDuration={180}>
475 <Tooltip>
476 <TooltipTrigger asChild>
477 <Button
478 size="sm"
479 variant="outline"
480 className="min-w-[132px]"
481 onClick={() => void handleImportSessionFile()}
482 disabled={importingSession}
483 >
484 <FileArchive className="mr-2 h-4 w-4" />
485 {importingSession ? t('sessions.importing') : t('sessions.importSessionFile')}
486 </Button>
487 </TooltipTrigger>
488 <TooltipContent side="bottom" align="end" className="whitespace-pre-line">
489 {t('sessions.importSessionFileTooltip')}
490 </TooltipContent>
491 </Tooltip>
492 </TooltipProvider>
493 <Button size="sm" className="min-w-[112px]" onClick={() => navigate('/')}>
494 <FolderOpen className="mr-2 h-4 w-4" />
495 {t('sessions.newSession')}
496 </Button>
497 </div>
498 </div>
499 </div>
500
501 {sessions.length === 0 ? (
502 <section className="flex min-h-[calc(100vh-220px)] items-center justify-center px-4 py-12">
503 <div className="flex w-full max-w-[460px] flex-col items-center text-center">
504 <div className="mb-6 flex h-16 w-16 items-center justify-center rounded-lg border border-[#d7cab1] bg-[#fff9ef] text-[#617052] shadow-[0_6px_16px_rgba(78,88,62,0.08)]">
505 <FolderOpen className="h-8 w-8" />
506 </div>
507 <h3 className="text-xl font-semibold text-[#3e4a32]">{t('sessions.emptyTitle')}</h3>
508 <p className="mt-2 text-sm leading-6 text-[#7b705f]">
509 {t('sessions.emptyDescription')}
510 </p>
511 <Button
512 className="mt-6 min-w-[148px] bg-[#5d6b4d] text-white hover:bg-[#4b593d]"
513 onClick={() => navigate('/')}
514 >
515 <FolderOpen className="mr-2 h-4 w-4" />
516 {t('sessions.newSession')}
517 </Button>
518 </div>
519 </section>
520 ) : filteredSessions.length === 0 ? (
521 <Card>
522 <CardContent className="flex flex-col items-center justify-center py-12 text-center">
523 <Search className="mb-4 h-10 w-10 text-muted-foreground" />
524 <h3 className="mb-2 text-lg font-medium">{t('sessions.noSearchResultsTitle')}</h3>
525 <p className="text-muted-foreground">{t('sessions.noSearchResultsDescription')}</p>
526 </CardContent>
527 </Card>
528 ) : (
529 <div className="grid grid-cols-2 gap-4">
530 {filteredSessions.map((session) => {
531 const editorGate = getEditorGate(session)
532 const activeRun = activeRuns[session.id]
533 const displayGeneratedCount = activeRun
534 ? Math.max(editorGate.generatedCount, activeRun.completedPageCount || 0)
535 : editorGate.generatedCount
536 const displayFailedCount = activeRun
537 ? activeRun.failedPageCount || 0
538 : editorGate.failedCount
539 const displayTotalCount = activeRun
540 ? Math.max(
541 editorGate.totalCount,
542 activeRun.totalPages,
543 displayGeneratedCount + displayFailedCount
544 )
545 : editorGate.totalCount
546 const hasCompletedPages = editorGate.generatedCount > 0
547 const isFullyComplete =
548 canEnterEditor(session) &&
549 editorGate.generatedCount >= editorGate.totalCount &&
550 editorGate.failedCount === 0
551 const isPartialComplete = !isFullyComplete && canEnterEditor(session)
552 const isContinuable = !isFullyComplete && !isPartialComplete && hasCompletedPages
553 const statusText = activeRun
554 ? activeRun.status === 'queued'
555 ? t('sessions.statusQueued')
556 : activeRun.progress > 0
557 ? t('sessions.statusGeneratingProgress', { progress: activeRun.progress })
558 : t('sessions.statusGenerating')
559 : isFullyComplete
560 ? t('sessions.statusComplete')
561 : isPartialComplete
562 ? t('sessions.statusPartialComplete')
563 : isContinuable
564 ? t('sessions.statusContinuable')
565 : t('sessions.statusRegenerate')
566 const actionText = activeRun
567 ? t('sessions.actionViewProgress')
568 : isFullyComplete || isPartialComplete
569 ? t('sessions.actionEnter')
570 : isContinuable
571 ? t('sessions.actionContinue')
572 : t('sessions.actionRegenerate')
573 const sourceTag = getSourceTag(session, {
574 pptx: t('sessions.sourcePptx'),
575 sessionFile: t('sessions.sourceSessionFile'),
576 saveAsNew: t('sessions.sourceSaveAsNew'),
577 document: t('sessions.sourceDocument'),
578 ai: t('sessions.sourceAi'),
579 thinking: t('sessions.sourceThinking'),
580 template: t('sessions.sourceTemplate')
581 })
582 const SourceIcon = sourceTag.Icon
583 const thumbnailPath = thumbnailPaths[session.id] || session.thumbnailPath || ''
584 const slideSize = resolveSessionSlideSize(session)
585 const sourceTagBaseClass =
586 'inline-flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold leading-none'
587 const statusClassName = activeRun
588 ? 'border-[#9fc7df]/80 bg-[#edf8ff] text-[#286a9a] shadow-[0_0_0_1px_rgba(116,182,229,0.14)]'
589 : isFullyComplete
590 ? 'border-[#bad8b7]/80 bg-[#eef9ec] text-[#4a7a46]'
591 : isPartialComplete
592 ? 'border-[#b5c9a8]/80 bg-[#eef5e8] text-[#4f7b3f]'
593 : isContinuable
594 ? 'border-[#d6c08d]/80 bg-[#fff3cf] text-[#7a5a19] shadow-[0_0_0_1px_rgba(214,192,141,0.14)]'
595 : 'border-[#d7b5ae]/70 bg-[#fbf1ee] text-[#93564f]'
596 return (
597 <Card
598 key={session.id}
599 data-session-card-id={session.id}
600 className="group flex h-full cursor-pointer flex-col overflow-hidden rounded-2xl border border-[#d8cfbc]/75 bg-white/70 shadow-[0_4px_16px_rgba(93,107,77,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_10px_26px_rgba(93,107,77,0.15)]"
601 title={isPartialComplete ? t('sessions.statusPartialCompleteTip') : undefined}
602 onClick={() => navigate(getSessionRoute(session))}
603 >
604 <div
605 className="relative flex h-[230px] w-full shrink-0 items-center justify-center overflow-hidden bg-[#f5f1e8]"
606 data-session-thumbnail-frame
607 >
608 {thumbnailPath ? (
609 <img
610 src={sessionThumbnailUrl(thumbnailPath)}
611 loading="lazy"
612 alt=""
613 aria-hidden="true"
614 className="max-h-full max-w-full object-contain transition-transform duration-300 group-hover:scale-[1.015]"
615 style={{ aspectRatio: `${slideSize.width}/${slideSize.height}` }}
616 />
617 ) : (
618 <img
619 src={sessionPlaceholder}
620 alt=""
621 aria-hidden="true"
622 className="max-h-full max-w-full object-contain transition-transform duration-300 group-hover:scale-[1.015]"
623 style={{ aspectRatio: `${slideSize.width}/${slideSize.height}` }}
624 />
625 )}
626 <div className="absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/45 via-black/10 to-transparent" />
627 <span className="absolute bottom-3 left-3 inline-flex items-center gap-1.5 rounded-lg bg-[#fffaf0]/92 px-2.5 py-1 text-xs font-semibold text-[#3e4a32] shadow-[0_4px_12px_rgba(31,38,29,0.16)] backdrop-blur-sm">
628 <MessageSquare className="h-3 w-3" />
629 {actionText}
630 </span>
631 </div>
632
633 <div className="min-w-0 flex-1 p-4">
634 <CardTitle className="line-clamp-2 min-h-10 text-base leading-5 text-[#3e4a32]">
635 {session.title}
636 </CardTitle>
637 <p className="mt-1.5 text-xs text-[#847866]">
638 {dayjs.unix(session.updated_at).format('YYYY/MM/DD HH:mm')}
639 </p>
640
641 <div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
642 <span
643 className={`rounded-lg border px-2 py-1 font-semibold ${statusClassName}`}
644 >
645 {statusText}
646 </span>
647 <span className={`${sourceTagBaseClass} ${sourceTag.className}`}>
648 <SourceIcon className={`h-3.5 w-3.5 ${sourceTag.iconClassName}`} />
649 {sourceTag.label}
650 </span>
651 <span className="rounded-lg border border-[#e1d1b7]/80 bg-[#fff7e8]/75 px-2 py-1 text-[#7c6a4c]">
652 {t('sessions.pagesCount', {
653 generated: displayGeneratedCount,
654 total: displayTotalCount
655 })}
656 </span>
657 {session.generation_duration_sec ? (
658 <span className="rounded-lg border border-[#d5cfc5]/60 bg-[#f9f6f1] px-2 py-1 text-[#6b6560]">
659 {(() => {
660 const d = dayjs.duration(session.generation_duration_sec!, 'second')
661 const m = Math.floor(d.asMinutes())
662 const s = d.seconds()
663 return m > 0 ? `${m}m ${s}s` : `${s}s`
664 })()}
665 </span>
666 ) : null}
667 {!isFullyComplete && displayFailedCount > 0 && (
668 <span className="rounded-lg border border-[#d7b5ae]/70 bg-[#fff7f2]/80 px-2 py-1 text-[#93564f]">
669 {t('sessions.failedCount', { count: displayFailedCount })}
670 </span>
671 )}
672 </div>
673 </div>
674
675 <div className="flex shrink-0 items-center justify-end gap-1 border-t border-[#e7dfd0]/70 bg-[#fffaf0]/45 px-3 py-2">
676 <TooltipProvider delayDuration={180}>
677 <Tooltip>
678 <TooltipTrigger asChild>
679 <Button
680 variant="ghost"
681 size="sm"
682 aria-label={t('sessions.editTitleTooltip')}
683 onClick={(e) => {
684 e.stopPropagation()
685 openRenameDialog(session)
686 }}
687 >
688 <Pencil className="h-4 w-4" />
689 </Button>
690 </TooltipTrigger>
691 <TooltipContent side="bottom" align="end">
692 {t('sessions.editTitleTooltip')}
693 </TooltipContent>
694 </Tooltip>
695 </TooltipProvider>
696 <TooltipProvider delayDuration={180}>
697 <Tooltip>
698 <TooltipTrigger asChild>
699 <span className="inline-flex" onClick={(event) => event.stopPropagation()}>
700 <Button
701 variant="ghost"
702 size="sm"
703 aria-label={t('sessions.saveTemplateTooltip')}
704 disabled={editorGate.generatedCount <= 0}
705 onClick={(event) => {
706 event.stopPropagation()
707 setSaveTemplateTarget(session)
708 }}
709 >
710 <LayoutTemplate className="h-4 w-4" />
711 </Button>
712 </span>
713 </TooltipTrigger>
714 <TooltipContent side="bottom" align="end">
715 {editorGate.generatedCount <= 0
716 ? t('sessions.saveTemplateTooltipDisabled')
717 : t('sessions.saveTemplateTooltip')}
718 </TooltipContent>
719 </Tooltip>
720 </TooltipProvider>
721 <Button
722 variant="ghost"
723 size="sm"
724 aria-label={t('common.delete')}
725 onClick={(e) => {
726 e.stopPropagation()
727 setDeleteSessionTarget(session)
728 }}
729 >
730 <Trash2 className="w-4 h-4" />
731 </Button>
732 </div>
733 </Card>
734 )
735 })}
736 </div>
737 )}
738 {renameSession ? (
739 <div
740 className="fixed inset-0 z-50 flex items-center justify-center bg-[#1f261d]/35 p-4 backdrop-blur-sm"
741 onClick={closeRenameDialog}
742 >
743 <div
744 className="w-full max-w-md rounded-xl border border-[#d8cfbc]/80 bg-[#fffaf0] p-5 shadow-[0_24px_60px_rgba(64,52,38,0.28)]"
745 onClick={(event) => event.stopPropagation()}
746 >
747 <div className="mb-4 flex items-start justify-between gap-3">
748 <div>
749 <h2 className="text-base font-semibold text-[#3e4a32]">
750 {t('sessions.editTitle')}
751 </h2>
752 <p className="mt-1 text-xs text-muted-foreground">
753 {t('sessions.renameDescription')}
754 </p>
755 </div>
756 <Button variant="ghost" size="sm" onClick={closeRenameDialog} disabled={renaming}>
757 <X className="h-4 w-4" />
758 </Button>
759 </div>
760 <form
761 className="space-y-4"
762 onSubmit={(event) => {
763 event.preventDefault()
764 void handleRenameSubmit()
765 }}
766 >
767 <Input
768 autoFocus
769 value={renameTitle}
770 maxLength={120}
771 placeholder={t('sessions.renamePlaceholder')}
772 onChange={(event) => setRenameTitle(event.target.value)}
773 />
774 <div className="flex justify-end gap-2">
775 <Button
776 type="button"
777 variant="outline"
778 size="sm"
779 onClick={closeRenameDialog}
780 disabled={renaming}
781 >
782 {t('common.cancel')}
783 </Button>
784 <Button type="submit" size="sm" disabled={renaming}>
785 {renaming ? t('common.saving') : t('common.save')}
786 </Button>
787 </div>
788 </form>
789 </div>
790 </div>
791 ) : null}
792 <Dialog
793 open={Boolean(deleteSessionTarget)}
794 onOpenChange={(open) => !open && closeDeleteDialog()}
795 >
796 <DialogContent showClose={false}>
797 <DialogHeader>
798 <DialogTitle>{t('sessions.deleteConfirmTitle')}</DialogTitle>
799 <DialogDescription>
800 {t('sessions.deleteConfirmDescription', { title: deleteSessionTarget?.title || '' })}
801 </DialogDescription>
802 </DialogHeader>
803 <DialogFooter>
804 <Button
805 type="button"
806 variant="outline"
807 size="sm"
808 onClick={closeDeleteDialog}
809 disabled={deleting}
810 >
811 {t('common.cancel')}
812 </Button>
813 <Button
814 type="button"
815 size="sm"
816 onClick={() => void handleDeleteSession()}
817 disabled={deleting}
818 >
819 {deleting ? t('common.saving') : t('common.delete')}
820 </Button>
821 </DialogFooter>
822 </DialogContent>
823 </Dialog>
824 <SaveTemplateDialog
825 open={Boolean(saveTemplateTarget)}
826 defaultName={saveTemplateTarget?.title || ''}
827 saving={savingTemplate}
828 onOpenChange={(open) => !open && setSaveTemplateTarget(null)}
829 onSubmit={(payload) => void handleSaveTemplate(payload)}
830 />
831 </div>
832 )
833 }
834
834 lines Plain Text