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