feat(w4): ship v1.5.0 timeline, character arc, and compliance
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TimelineConflict, TimelineEntry } from '@shared/timeline'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function TimelineModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useAppStore((s) => s.timelineModalOpen)
|
||||
const setOpen = useAppStore((s) => s.setTimelineModalOpen)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const [entries, setEntries] = useState<TimelineEntry[]>([])
|
||||
const [conflicts, setConflicts] = useState<TimelineConflict[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const conflictChapterIds = useMemo(
|
||||
() => new Set(conflicts.map((c) => c.chapterId)),
|
||||
[conflicts]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId) return
|
||||
setLoading(true)
|
||||
void Promise.all([
|
||||
ipcCall(() => window.electronAPI.timeline.list(bookId)),
|
||||
ipcCall(() => window.electronAPI.timeline.conflicts(bookId))
|
||||
])
|
||||
.then(([list, conf]) => {
|
||||
setEntries(list)
|
||||
setConflicts(conf)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, bookId])
|
||||
|
||||
const handleJump = async (entry: TimelineEntry): Promise<void> => {
|
||||
if (!entry.chapterId) return
|
||||
await switchTarget({ kind: 'chapter', id: entry.chapterId })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleAddEvent = async (): Promise<void> => {
|
||||
if (!bookId) return
|
||||
const title = window.prompt(t('timeline.eventTitlePrompt'))
|
||||
if (!title?.trim()) return
|
||||
const storyTime = window.prompt(t('timeline.storyTimePrompt')) ?? ''
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.timeline.upsert(bookId, {
|
||||
title: title.trim(),
|
||||
storyTime: storyTime.trim() || null
|
||||
})
|
||||
)
|
||||
const list = await ipcCall(() => window.electronAPI.timeline.list(bookId))
|
||||
setEntries(list)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" data-testid="timeline-modal" role="dialog" aria-modal="true">
|
||||
<div className="modal-content modal-content--wide">
|
||||
<div className="modal-header">
|
||||
<h3>{t('timeline.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{loading ? (
|
||||
<p>{t('cockpit.loading')}</p>
|
||||
) : (
|
||||
<div className="timeline-list" data-testid="timeline-list">
|
||||
{entries.map((entry) => {
|
||||
const conflict = conflicts.find((c) => c.chapterId === entry.chapterId)
|
||||
const isConflict = entry.chapterId && conflictChapterIds.has(entry.chapterId)
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`timeline-item ${isConflict ? 'timeline-item--conflict' : ''}`}
|
||||
data-testid={`timeline-item-${entry.id}`}
|
||||
>
|
||||
<div className="timeline-item-main">
|
||||
<strong>{entry.title}</strong>
|
||||
<span className="timeline-item-meta">
|
||||
{entry.storyTime ? entry.storyTime : t('timeline.noTime')}
|
||||
{entry.kind === 'manual' ? ` · ${t('timeline.manual')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{conflict && (
|
||||
<div className="timeline-conflict-msg" data-testid="timeline-conflict-msg">
|
||||
{conflict.message}
|
||||
</div>
|
||||
)}
|
||||
{entry.chapterId && (
|
||||
<button type="button" className="btn" onClick={() => void handleJump(entry)}>
|
||||
{t('timeline.jumpChapter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{entries.length === 0 && <p className="text-muted">{t('timeline.empty')}</p>}
|
||||
</div>
|
||||
)}
|
||||
{settings.filter((s) => s.type === 'character').length > 0 && (
|
||||
<p className="text-muted" style={{ marginTop: 12, fontSize: 12 }}>
|
||||
{t('timeline.characterHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={() => void handleAddEvent()}>
|
||||
{t('timeline.addEvent')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setOpen(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user