From 2721e07027abbe3f0067b00d9e9dde646ffdb123 Mon Sep 17 00:00:00 2001 From: DankerMu Date: Sat, 21 Feb 2026 20:41:41 +0800 Subject: [PATCH 1/2] feat(frontend): add book/chapter/scene create buttons in project tree (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add inline creation buttons for books, chapters, and scenes in the sidebar project tree. Click the button → type title → Enter to create, Escape to cancel. Tree auto-refreshes after creation. Closes #49 Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/project-tree.tsx | 216 ++++++++++++++++++++--- 1 file changed, 189 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/project-tree.tsx b/frontend/src/components/project-tree.tsx index fddf6b7..aa66f2d 100644 --- a/frontend/src/components/project-tree.tsx +++ b/frontend/src/components/project-tree.tsx @@ -1,14 +1,24 @@ 'use client' -import { useQuery } from '@tanstack/react-query' +import { useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' import { useWorkspace } from '@/hooks/use-workspace' -import type { ProjectTree } from '@/lib/types' -import { useState } from 'react' +import type { Book, Chapter, ProjectTree, Scene } from '@/lib/types' + +type AddingTarget = + | { type: 'book' } + | { type: 'chapter'; bookId: number } + | { type: 'scene'; chapterId: number } + | null export function ProjectTreePanel() { const { projectId, selectedSceneId, selectScene } = useWorkspace() + const queryClient = useQueryClient() const [collapsed, setCollapsed] = useState>({}) + const [adding, setAdding] = useState(null) + const [newTitle, setNewTitle] = useState('') + const inputRef = useRef(null) const { data: tree } = useQuery({ queryKey: ['project-tree', projectId], @@ -20,6 +30,70 @@ export function ProjectTreePanel() { setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })) } + const invalidateTree = () => { + queryClient.invalidateQueries({ queryKey: ['project-tree', projectId] }) + } + + const resetAdding = () => { + setAdding(null) + setNewTitle('') + } + + const createBook = useMutation({ + mutationFn: (title: string) => + apiFetch('/api/books', { + method: 'POST', + body: JSON.stringify({ project_id: projectId, title }), + }), + onSuccess: () => { + invalidateTree() + resetAdding() + }, + }) + + const createChapter = useMutation({ + mutationFn: ({ bookId, title }: { bookId: number; title: string }) => + apiFetch('/api/chapters', { + method: 'POST', + body: JSON.stringify({ book_id: bookId, title }), + }), + onSuccess: () => { + invalidateTree() + resetAdding() + }, + }) + + const createScene = useMutation({ + mutationFn: ({ chapterId, title }: { chapterId: number; title: string }) => + apiFetch('/api/scenes', { + method: 'POST', + body: JSON.stringify({ chapter_id: chapterId, title }), + }), + onSuccess: () => { + invalidateTree() + resetAdding() + }, + }) + + const handleSubmit = () => { + const t = newTitle.trim() + if (!t || !adding) return + if (adding.type === 'book') createBook.mutate(t) + else if (adding.type === 'chapter') createChapter.mutate({ bookId: adding.bookId, title: t }) + else createScene.mutate({ chapterId: adding.chapterId, title: t }) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSubmit() + else if (e.key === 'Escape') resetAdding() + } + + const startAdding = (target: AddingTarget) => { + setAdding(target) + setNewTitle('') + setTimeout(() => inputRef.current?.focus(), 0) + } + if (!projectId) { return
请选择项目
} @@ -28,9 +102,12 @@ export function ProjectTreePanel() { return
加载中...
} + const isPending = createBook.isPending || createChapter.isPending || createScene.isPending + return (

{tree.title}

+ {tree.books.map((book) => (
- {!collapsed[`book-${book.id}`] && book.chapters.map((chapter) => ( -
- - {!collapsed[`ch-${chapter.id}`] && chapter.scenes.map((scene) => ( + + {!collapsed[`book-${book.id}`] && ( + <> + {book.chapters.map((chapter) => ( +
+ + + {!collapsed[`ch-${chapter.id}`] && ( + <> + {chapter.scenes.map((scene) => ( + + ))} + + {/* Inline: add scene */} + {adding?.type === 'scene' && adding.chapterId === chapter.id ? ( +
+ setNewTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { if (!newTitle.trim()) resetAdding() }} + disabled={isPending} + /> +
+ ) : ( + + )} + + )} +
+ ))} + + {/* Inline: add chapter */} + {adding?.type === 'chapter' && adding.bookId === book.id ? ( +
+ setNewTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { if (!newTitle.trim()) resetAdding() }} + disabled={isPending} + /> +
+ ) : ( - ))} -
- ))} + )} + + )}
))} + + {/* Inline: add book */} + {adding?.type === 'book' ? ( +
+ setNewTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { if (!newTitle.trim()) resetAdding() }} + disabled={isPending} + /> +
+ ) : ( + + )}
) } From a00927aac2380f6f0b871b75d55ee0f51f108df3 Mon Sep 17 00:00:00 2001 From: DankerMu Date: Sat, 21 Feb 2026 20:46:25 +0800 Subject: [PATCH 2/2] fix(project-tree): add error handling, blur-submit, useEffect focus Address review findings: - Add onError callbacks to all three mutations with user-facing toast - Auto-submit on blur when input is non-empty (instead of discarding) - Replace setTimeout focus hack with useEffect on adding state Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/project-tree.tsx | 44 ++++++++++++++++-------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/project-tree.tsx b/frontend/src/components/project-tree.tsx index aa66f2d..298de14 100644 --- a/frontend/src/components/project-tree.tsx +++ b/frontend/src/components/project-tree.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' import { useWorkspace } from '@/hooks/use-workspace' @@ -18,8 +18,13 @@ export function ProjectTreePanel() { const [collapsed, setCollapsed] = useState>({}) const [adding, setAdding] = useState(null) const [newTitle, setNewTitle] = useState('') + const [error, setError] = useState('') const inputRef = useRef(null) + useEffect(() => { + if (adding) inputRef.current?.focus() + }, [adding]) + const { data: tree } = useQuery({ queryKey: ['project-tree', projectId], queryFn: () => apiFetch(`/api/projects/${projectId}/tree`), @@ -39,16 +44,19 @@ export function ProjectTreePanel() { setNewTitle('') } + const onMutationError = () => { + setError('创建失败,请重试') + setTimeout(() => setError(''), 3000) + } + const createBook = useMutation({ mutationFn: (title: string) => apiFetch('/api/books', { method: 'POST', body: JSON.stringify({ project_id: projectId, title }), }), - onSuccess: () => { - invalidateTree() - resetAdding() - }, + onSuccess: () => { invalidateTree(); resetAdding() }, + onError: onMutationError, }) const createChapter = useMutation({ @@ -57,10 +65,8 @@ export function ProjectTreePanel() { method: 'POST', body: JSON.stringify({ book_id: bookId, title }), }), - onSuccess: () => { - invalidateTree() - resetAdding() - }, + onSuccess: () => { invalidateTree(); resetAdding() }, + onError: onMutationError, }) const createScene = useMutation({ @@ -69,10 +75,8 @@ export function ProjectTreePanel() { method: 'POST', body: JSON.stringify({ chapter_id: chapterId, title }), }), - onSuccess: () => { - invalidateTree() - resetAdding() - }, + onSuccess: () => { invalidateTree(); resetAdding() }, + onError: onMutationError, }) const handleSubmit = () => { @@ -88,10 +92,15 @@ export function ProjectTreePanel() { else if (e.key === 'Escape') resetAdding() } + const handleBlur = () => { + if (newTitle.trim()) handleSubmit() + else resetAdding() + } + const startAdding = (target: AddingTarget) => { setAdding(target) setNewTitle('') - setTimeout(() => inputRef.current?.focus(), 0) + setError('') } if (!projectId) { @@ -106,6 +115,11 @@ export function ProjectTreePanel() { return (
+ {error && ( +
+ {error} +
+ )}

{tree.title}

{tree.books.map((book) => ( @@ -159,7 +173,7 @@ export function ProjectTreePanel() { value={newTitle} onChange={(e) => setNewTitle(e.target.value)} onKeyDown={handleKeyDown} - onBlur={() => { if (!newTitle.trim()) resetAdding() }} + onBlur={handleBlur} disabled={isPending} />