feat(ui): Sidebar + NotebookList + NotebookCreateModal stub

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-15 10:47:07 +09:00
parent 53a1579266
commit 7b3450d0d5
5 changed files with 174 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import React from 'react';
// Stub for Task 12 — full implementation in Task 13.
export function NotebookCreateModal({ onClose }: { onClose: () => void }): React.ReactElement {
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 120 }}
onClick={onClose}
>
<div style={{ background: '#fff', padding: 20, margin: '20% auto', width: 320 }}>
(NotebookCreateModal Task 13 )
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import type { Notebook } from '@shared/types';
interface Props {
notebooks: Notebook[];
selectedId: string | null;
onSelect: (id: string) => void;
onCreate: () => void;
}
export function NotebookList({ notebooks, selectedId, onSelect, onCreate }: Props): React.ReactElement {
return (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{notebooks.map((nb) => {
const active = nb.id === selectedId;
return (
<button
key={nb.id}
onClick={() => onSelect(nb.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 12px', background: active ? '#eaf3ff' : 'transparent',
border: 'none', cursor: 'pointer', textAlign: 'left',
color: active ? '#0a4b80' : '#333', fontSize: 13
}}
>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: nb.color ?? '#bbb', flexShrink: 0
}} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{nb.name}
</span>
<span style={{ fontSize: 11, color: '#888' }}>{nb.noteCount}</span>
</button>
);
})}
<button
onClick={onCreate}
style={{
padding: '6px 12px', background: 'transparent', border: 'none',
cursor: 'pointer', textAlign: 'left', color: '#888', fontSize: 12
}}
>
+
</button>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import React, { useState } from 'react';
import { useInbox } from '../store.js';
import { NotebookList } from './NotebookList.js';
import { NotebookCreateModal } from './NotebookCreateModal.js';
export function Sidebar(): React.ReactElement | null {
const visible = useInbox((s) => s.sidebarVisible);
const width = useInbox((s) => s.sidebarWidth);
const notebooks = useInbox((s) => s.notebooks);
const selectedId = useInbox((s) => s.selectedNotebookId);
const selectNotebook = useInbox((s) => s.selectNotebook);
const [createOpen, setCreateOpen] = useState(false);
if (!visible) return null;
return (
<aside style={{
width, borderRight: '1px solid #e0e0e0',
background: '#fafafa', overflowY: 'auto',
flexShrink: 0
}}>
<NotebookList
notebooks={notebooks}
selectedId={selectedId}
onSelect={selectNotebook}
onCreate={() => setCreateOpen(true)}
/>
{createOpen && <NotebookCreateModal onClose={() => setCreateOpen(false)} />}
</aside>
);
}

View File

@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { NotebookList } from '../../src/renderer/inbox/components/NotebookList';
const notebooks = [
{ id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 3 },
{ id: 'nb-2', name: '회사', color: '#0a4b80', createdAt: 't', updatedAt: 't', noteCount: 7 }
];
describe('NotebookList', () => {
beforeEach(cleanup);
it('노트북 이름 + count 렌더링', () => {
render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={() => {}} />);
expect(screen.getByText('기본')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('회사')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
});
it('클릭 시 onSelect 호출', () => {
const onSelect = vi.fn();
render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={onSelect} onCreate={() => {}} />);
fireEvent.click(screen.getByText('회사'));
expect(onSelect).toHaveBeenCalledWith('nb-2');
});
it('+ 새 노트북 클릭 시 onCreate 호출', () => {
const onCreate = vi.fn();
render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={onCreate} />);
fireEvent.click(screen.getByRole('button', { name: /새 노트북/ }));
expect(onCreate).toHaveBeenCalled();
});
it('selected notebook 의 background 가 다름', () => {
render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={() => {}} />);
const btn1 = screen.getByText('기본').closest('button')!;
const btn2 = screen.getByText('회사').closest('button')!;
expect(btn1.style.background).not.toBe('transparent');
expect(btn2.style.background).toBe('transparent');
});
});

View File

@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, cleanup } from '@testing-library/react';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {} as never,
notebookApi: {} as never
}));
import { Sidebar } from '../../src/renderer/inbox/components/Sidebar';
import { useInbox } from '../../src/renderer/inbox/store';
describe('Sidebar', () => {
beforeEach(() => {
cleanup();
useInbox.setState({ sidebarVisible: false, sidebarWidth: 240, notebooks: [], selectedNotebookId: null } as never);
});
it('sidebarVisible=false 면 렌더링 안 함', () => {
const { container } = render(<Sidebar />);
expect(container.firstChild).toBeNull();
});
it('sidebarVisible=true 면 NotebookList 렌더링', () => {
useInbox.setState({
sidebarVisible: true,
sidebarWidth: 240,
notebooks: [{ id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }],
selectedNotebookId: 'nb-1'
} as never);
render(<Sidebar />);
expect(screen.getByText('기본')).toBeInTheDocument();
});
});