@yargram/react 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.storybook/main.ts +21 -0
  2. package/.storybook/preview.ts +21 -0
  3. package/dist/components/LogWindow/LogEntryRow.d.ts +8 -0
  4. package/dist/components/LogWindow/LogEntryRow.d.ts.map +1 -0
  5. package/dist/components/LogWindow/LogEntryRow.js +14 -0
  6. package/dist/components/LogWindow/LogWindow.d.ts +41 -0
  7. package/dist/components/LogWindow/LogWindow.d.ts.map +1 -0
  8. package/dist/components/LogWindow/LogWindow.js +144 -0
  9. package/dist/components/LogWindow/LogWindow.stories.d.ts +29 -0
  10. package/dist/components/LogWindow/LogWindow.stories.d.ts.map +1 -0
  11. package/dist/components/LogWindow/LogWindow.stories.js +183 -0
  12. package/dist/components/LogWindow/LogWindow.test.d.ts +2 -0
  13. package/dist/components/LogWindow/LogWindow.test.d.ts.map +1 -0
  14. package/dist/components/LogWindow/LogWindow.test.js +61 -0
  15. package/dist/components/LogWindow/LogWindowEscapeDemo.d.ts +12 -0
  16. package/dist/components/LogWindow/LogWindowEscapeDemo.d.ts.map +1 -0
  17. package/dist/components/LogWindow/LogWindowEscapeDemo.js +56 -0
  18. package/dist/components/LogWindow/NetworkEntryRow.d.ts +8 -0
  19. package/dist/components/LogWindow/NetworkEntryRow.d.ts.map +1 -0
  20. package/dist/components/LogWindow/NetworkEntryRow.js +32 -0
  21. package/dist/components/LogWindow/index.d.ts +7 -0
  22. package/dist/components/LogWindow/index.d.ts.map +1 -0
  23. package/dist/components/LogWindow/index.js +4 -0
  24. package/dist/components/LogWindow/types.d.ts +36 -0
  25. package/dist/components/LogWindow/types.d.ts.map +1 -0
  26. package/dist/components/LogWindow/types.js +1 -0
  27. package/dist/components/LoginWindow/LoginForm.d.ts +11 -0
  28. package/dist/components/LoginWindow/LoginForm.d.ts.map +1 -0
  29. package/dist/components/LoginWindow/LoginForm.js +28 -0
  30. package/dist/components/LoginWindow/LoginForm.test.d.ts +2 -0
  31. package/dist/components/LoginWindow/LoginForm.test.d.ts.map +1 -0
  32. package/dist/components/LoginWindow/LoginForm.test.js +34 -0
  33. package/dist/components/LoginWindow/LoginWindow.d.ts +15 -0
  34. package/dist/components/LoginWindow/LoginWindow.d.ts.map +1 -0
  35. package/dist/components/LoginWindow/LoginWindow.js +27 -0
  36. package/dist/components/LoginWindow/index.d.ts +3 -0
  37. package/dist/components/LoginWindow/index.d.ts.map +1 -0
  38. package/dist/components/LoginWindow/index.js +1 -0
  39. package/dist/contexts/ApiContext.d.ts +35 -0
  40. package/dist/contexts/ApiContext.d.ts.map +1 -0
  41. package/dist/contexts/ApiContext.js +82 -0
  42. package/dist/contexts/ApiContext.test.d.ts +2 -0
  43. package/dist/contexts/ApiContext.test.d.ts.map +1 -0
  44. package/dist/contexts/ApiContext.test.js +45 -0
  45. package/dist/contexts/PrinterContext.d.ts +12 -0
  46. package/dist/contexts/PrinterContext.d.ts.map +1 -0
  47. package/dist/contexts/PrinterContext.js +17 -0
  48. package/dist/contexts/PrinterContext.test.d.ts +2 -0
  49. package/dist/contexts/PrinterContext.test.d.ts.map +1 -0
  50. package/dist/contexts/PrinterContext.test.js +19 -0
  51. package/dist/contexts/YahmanContext.d.ts +69 -0
  52. package/dist/contexts/YahmanContext.d.ts.map +1 -0
  53. package/dist/contexts/YahmanContext.js +414 -0
  54. package/dist/contexts/YahmanContext.stories.d.ts +16 -0
  55. package/dist/contexts/YahmanContext.stories.d.ts.map +1 -0
  56. package/dist/contexts/YahmanContext.stories.js +64 -0
  57. package/dist/contexts/YargramContext.d.ts +69 -0
  58. package/dist/contexts/YargramContext.d.ts.map +1 -0
  59. package/dist/contexts/YargramContext.js +414 -0
  60. package/dist/contexts/YargramContext.stories.d.ts +16 -0
  61. package/dist/contexts/YargramContext.stories.d.ts.map +1 -0
  62. package/dist/contexts/YargramContext.stories.js +64 -0
  63. package/dist/contexts/YargramContext.test.d.ts +2 -0
  64. package/dist/contexts/YargramContext.test.d.ts.map +1 -0
  65. package/dist/contexts/YargramContext.test.js +54 -0
  66. package/dist/hooks/useLogWindowShortcut.d.ts +24 -0
  67. package/dist/hooks/useLogWindowShortcut.d.ts.map +1 -0
  68. package/dist/hooks/useLogWindowShortcut.js +61 -0
  69. package/dist/hooks/useLogWindowShortcut.test.d.ts +2 -0
  70. package/dist/hooks/useLogWindowShortcut.test.d.ts.map +1 -0
  71. package/dist/hooks/useLogWindowShortcut.test.js +93 -0
  72. package/dist/index.d.ts +6 -0
  73. package/dist/index.d.ts.map +1 -0
  74. package/dist/index.js +7 -0
  75. package/dist/test/setup.d.ts +2 -0
  76. package/dist/test/setup.d.ts.map +1 -0
  77. package/dist/test/setup.js +1 -0
  78. package/package.json +49 -0
  79. package/src/components/LogWindow/LogEntryRow.tsx +38 -0
  80. package/src/components/LogWindow/LogWindow.css +614 -0
  81. package/src/components/LogWindow/LogWindow.stories.tsx +206 -0
  82. package/src/components/LogWindow/LogWindow.test.tsx +68 -0
  83. package/src/components/LogWindow/LogWindow.tsx +379 -0
  84. package/src/components/LogWindow/LogWindowEscapeDemo.tsx +100 -0
  85. package/src/components/LogWindow/NetworkEntryRow.tsx +102 -0
  86. package/src/components/LogWindow/index.ts +13 -0
  87. package/src/components/LogWindow/types.ts +40 -0
  88. package/src/components/LoginWindow/LoginForm.test.tsx +38 -0
  89. package/src/components/LoginWindow/LoginForm.tsx +78 -0
  90. package/src/components/LoginWindow/LoginWindow.css +198 -0
  91. package/src/components/LoginWindow/LoginWindow.tsx +90 -0
  92. package/src/components/LoginWindow/index.ts +2 -0
  93. package/src/contexts/ApiContext.test.tsx +68 -0
  94. package/src/contexts/ApiContext.tsx +155 -0
  95. package/src/contexts/PrinterContext.test.tsx +37 -0
  96. package/src/contexts/PrinterContext.tsx +35 -0
  97. package/src/contexts/YargramContext.stories.tsx +148 -0
  98. package/src/contexts/YargramContext.test.tsx +105 -0
  99. package/src/contexts/YargramContext.tsx +676 -0
  100. package/src/hooks/useLogWindowShortcut.test.ts +111 -0
  101. package/src/hooks/useLogWindowShortcut.ts +96 -0
  102. package/src/index.ts +14 -0
  103. package/src/test/setup.ts +1 -0
  104. package/tsconfig.json +16 -0
  105. package/vitest.config.ts +18 -0
@@ -0,0 +1,206 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { LogWindow } from './LogWindow';
3
+ import { LogWindowEscapeDemo } from './LogWindowEscapeDemo';
4
+ import type { LogEntry, NetworkEntryRest, NetworkEntryGraphql } from './types';
5
+
6
+ const sampleEntries: LogEntry[] = [
7
+ {
8
+ id: '1',
9
+ level: 'info',
10
+ message: 'Hello!!',
11
+ source: 'ArticleContainer.tsx:32',
12
+ },
13
+ {
14
+ id: '2',
15
+ level: 'warn',
16
+ message: 'Hello!!',
17
+ source: 'ArticleContainer.tsx:32',
18
+ },
19
+ {
20
+ id: '3',
21
+ level: 'error',
22
+ message: 'Hello!!',
23
+ source: 'ArticleContainer.tsx:32',
24
+ },
25
+ ];
26
+
27
+ const sampleNetworkRest: NetworkEntryRest[] = [
28
+ {
29
+ id: 'r1',
30
+ type: 'rest',
31
+ method: 'GET',
32
+ url: '/api/users',
33
+ status: 200,
34
+ statusText: 'OK',
35
+ request: 'GET /api/users HTTP/1.1\nAccept: application/json',
36
+ response: '{\n "data": [\n { "id": "1", "name": "Alice" },\n { "id": "2", "name": "Bob" }\n ]\n}',
37
+ },
38
+ {
39
+ id: 'r2',
40
+ type: 'rest',
41
+ method: 'POST',
42
+ url: '/api/auth/login',
43
+ status: 201,
44
+ statusText: 'Created',
45
+ request: 'POST /api/auth/login HTTP/1.1\nContent-Type: application/json\n\n{"email":"user@example.com","password":"***"}',
46
+ response: '{\n "token": "eyJhbGciOiJIUzI1NiIs...",\n "user": { "id": "1", "email": "user@example.com" }\n}',
47
+ },
48
+ {
49
+ id: 'r3',
50
+ type: 'rest',
51
+ method: 'GET',
52
+ url: '/api/articles/123',
53
+ status: 404,
54
+ statusText: 'Not Found',
55
+ request: 'GET /api/articles/123 HTTP/1.1',
56
+ response: '{\n "error": "Not Found",\n "message": "Article 123 does not exist"\n}',
57
+ },
58
+ ];
59
+
60
+ const sampleNetworkGraphql: NetworkEntryGraphql[] = [
61
+ {
62
+ id: 'g1',
63
+ type: 'graphql',
64
+ operationName: 'GetUser',
65
+ url: '/graphql',
66
+ status: 200,
67
+ statusText: 'OK',
68
+ request: 'query GetUser($id: ID!) {\n user(id: $id) {\n id\n name\n email\n }\n}\n\nVariables: { "id": "1" }',
69
+ response: '{\n "data": {\n "user": {\n "id": "1",\n "name": "Alice",\n "email": "alice@example.com"\n }\n }\n}',
70
+ },
71
+ {
72
+ id: 'g2',
73
+ type: 'graphql',
74
+ operationName: 'ListArticles',
75
+ url: '/graphql',
76
+ status: 200,
77
+ statusText: 'OK',
78
+ request: 'query ListArticles {\n articles {\n id\n title\n publishedAt\n }\n}',
79
+ response: '{\n "data": {\n "articles": [\n { "id": "1", "title": "Hello", "publishedAt": "2024-01-01" }\n ]\n }\n}',
80
+ },
81
+ {
82
+ id: 'g3',
83
+ type: 'graphql',
84
+ operationName: 'CreatePost',
85
+ url: '/graphql',
86
+ status: 400,
87
+ statusText: 'Bad Request',
88
+ request: 'mutation CreatePost($input: CreatePostInput!) {\n createPost(input: $input) { id }\n}',
89
+ response: '{\n "errors": [\n { "message": "Validation error: title is required" }\n ]\n}',
90
+ },
91
+ ];
92
+
93
+ const meta: Meta<typeof LogWindow> = {
94
+ title: 'Components/LogWindow',
95
+ component: LogWindow,
96
+ parameters: {
97
+ layout: 'centered',
98
+ },
99
+ tags: ['autodocs'],
100
+ argTypes: {
101
+ defaultTab: {
102
+ control: 'radio',
103
+ options: ['logs', 'networks'],
104
+ },
105
+ },
106
+ };
107
+
108
+ export default meta;
109
+
110
+ type Story = StoryObj<typeof LogWindow>;
111
+
112
+ export const Default: Story = {
113
+ args: {
114
+ entries: sampleEntries,
115
+ defaultTab: 'logs',
116
+ },
117
+ };
118
+
119
+ export const InfoOnly: Story = {
120
+ args: {
121
+ entries: [
122
+ { id: '1', level: 'info', message: 'Application started', source: 'App.tsx:12' },
123
+ { id: '2', level: 'info', message: 'User logged in', source: 'Auth.tsx:45' },
124
+ ],
125
+ },
126
+ };
127
+
128
+ export const ManyEntries: Story = {
129
+ args: {
130
+ entries: [
131
+ ...sampleEntries,
132
+ { id: '4', level: 'info', message: 'Fetching data...', source: 'useApi.ts:18' },
133
+ { id: '5', level: 'warn', message: 'Deprecated API used', source: 'legacy.ts:3' },
134
+ { id: '6', level: 'error', message: 'Network request failed', source: 'api.ts:92' },
135
+ ],
136
+ height: 280,
137
+ },
138
+ };
139
+
140
+ export const Empty: Story = {
141
+ args: {
142
+ entries: [],
143
+ },
144
+ };
145
+
146
+ export const NetworksTab: Story = {
147
+ args: {
148
+ entries: sampleEntries,
149
+ defaultTab: 'networks',
150
+ },
151
+ };
152
+
153
+ /** Networks タブで REST API リクエストを表示 */
154
+ export const NetworkREST: Story = {
155
+ args: {
156
+ networkEntries: sampleNetworkRest,
157
+ defaultTab: 'networks',
158
+ },
159
+ };
160
+
161
+ /** Networks タブで GraphQL リクエストを表示 */
162
+ export const NetworkGraphQL: Story = {
163
+ args: {
164
+ networkEntries: sampleNetworkGraphql,
165
+ defaultTab: 'networks',
166
+ },
167
+ };
168
+
169
+ /** REST と GraphQL の両方のネットワークエントリを表示 */
170
+ export const NetworkMixed: Story = {
171
+ args: {
172
+ networkEntries: [
173
+ ...sampleNetworkRest,
174
+ ...sampleNetworkGraphql,
175
+ ],
176
+ defaultTab: 'networks',
177
+ },
178
+ };
179
+
180
+ /**
181
+ * ヘッダーをドラッグしてウィンドウを自由に移動できます。
182
+ * タブ(Logs / Networks)をクリックしてもドラッグは開始されません。
183
+ */
184
+ export const Draggable: Story = {
185
+ args: {
186
+ entries: sampleEntries,
187
+ networkEntries: [...sampleNetworkRest, ...sampleNetworkGraphql],
188
+ draggable: true,
189
+ defaultPosition: { x: 80, y: 80 },
190
+ },
191
+ };
192
+
193
+ /**
194
+ * Escape キーを 5 回押すとログウィンドウがオーバーレイで開きます。
195
+ * テスト手順: ストーリーを開き、Escape を 5 回連続で押す(約 1.5 秒以内に次のキーを押す)。
196
+ * ログウィンドウ表示中に Escape を 1 回押すか、背景をクリックすると閉じます。
197
+ * 開いたウィンドウはヘッダーをドラッグして移動できます。
198
+ */
199
+ export const EscapeToOpen: Story = {
200
+ render: () => (
201
+ <LogWindowEscapeDemo
202
+ entries={sampleEntries}
203
+ networkEntries={[...sampleNetworkRest, ...sampleNetworkGraphql]}
204
+ />
205
+ ),
206
+ };
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { LogWindow } from './LogWindow';
5
+
6
+ describe('LogWindow', () => {
7
+ it('renders with empty entries by default', () => {
8
+ render(<LogWindow />);
9
+ expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
10
+ expect(screen.getByRole('button', { name: /networks/i })).toBeInTheDocument();
11
+ });
12
+
13
+ it('renders log entries', () => {
14
+ const entries = [
15
+ { id: '1', level: 'info' as const, message: 'Test message', source: 'app' },
16
+ { id: '2', level: 'warn' as const, message: 'Warning', source: 'app' },
17
+ ];
18
+ render(<LogWindow entries={entries} />);
19
+ expect(screen.getByText('Test message')).toBeInTheDocument();
20
+ expect(screen.getByText('Warning')).toBeInTheDocument();
21
+ });
22
+
23
+ it('renders network entries in Networks tab', async () => {
24
+ const user = userEvent.setup();
25
+ const networkEntries = [
26
+ {
27
+ id: 'n1',
28
+ type: 'rest' as const,
29
+ method: 'GET',
30
+ url: 'https://api.example.com/posts',
31
+ status: 200,
32
+ statusText: 'OK',
33
+ request: 'GET /posts',
34
+ response: '[]',
35
+ },
36
+ ];
37
+ render(<LogWindow networkEntries={networkEntries} />);
38
+ const networksTab = screen.getByRole('button', { name: /networks/i });
39
+ await user.click(networksTab);
40
+ expect(screen.getByText(/GET.*posts/i)).toBeInTheDocument();
41
+ });
42
+
43
+ it('shows login form when showLogin is true and onLogin provided', () => {
44
+ const onLogin = vi.fn().mockResolvedValue(undefined);
45
+ render(
46
+ <LogWindow showLogin loginTitle="Sign in" onLogin={onLogin} />
47
+ );
48
+ expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
49
+ expect(screen.getByRole('button', { name: /log in/i })).toBeInTheDocument();
50
+ expect(screen.getAllByText('Sign in').length).toBeGreaterThan(0);
51
+ });
52
+
53
+ it('when showLogin, does not show Logs/Networks tabs', () => {
54
+ render(<LogWindow showLogin onLogin={async () => {}} />);
55
+ expect(screen.queryByRole('button', { name: /^logs$/i })).not.toBeInTheDocument();
56
+ expect(screen.queryByRole('button', { name: /^networks$/i })).not.toBeInTheDocument();
57
+ });
58
+
59
+ it('calls onClose when close button is clicked', async () => {
60
+ const user = userEvent.setup();
61
+ const onClose = vi.fn();
62
+ render(<LogWindow onClose={onClose} />);
63
+ const closeBtn = screen.getByRole('button', { name: /close/i });
64
+ await user.click(closeBtn);
65
+ await new Promise((r) => setTimeout(r, 250));
66
+ expect(onClose).toHaveBeenCalled();
67
+ });
68
+ });
@@ -0,0 +1,379 @@
1
+ import React, { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { Terminal, Wifi, X, Download, FileJson, FileText, LogOut } from 'lucide-react';
3
+ import type { LogEntry, LogWindowTab, NetworkEntry } from './types';
4
+ import { LogEntryRow } from './LogEntryRow';
5
+ import { NetworkEntryRow } from './NetworkEntryRow';
6
+ import { LoginForm } from '../LoginWindow/LoginForm';
7
+ import '../LoginWindow/LoginWindow.css';
8
+ import './LogWindow.css';
9
+
10
+ function escapeCsvCell(value: string): string {
11
+ if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
12
+ return value;
13
+ }
14
+
15
+ function downloadBlob(blob: Blob, filename: string) {
16
+ const url = URL.createObjectURL(blob);
17
+ const a = document.createElement('a');
18
+ a.href = url;
19
+ a.download = filename;
20
+ a.click();
21
+ URL.revokeObjectURL(url);
22
+ }
23
+
24
+ export type LogWindowProps = {
25
+ /** 表示するログエントリの配列 */
26
+ entries?: LogEntry[];
27
+ /** Networks タブで表示するネットワークエントリ(REST / GraphQL) */
28
+ networkEntries?: NetworkEntry[];
29
+ /** アクティブなタブ(未指定時は 'logs') */
30
+ defaultTab?: LogWindowTab;
31
+ /** タブ切り替え時のコールバック */
32
+ onTabChange?: (tab: LogWindowTab) => void;
33
+ /** 高さ(CSS 値)。未指定時は max-height: 320px */
34
+ height?: string | number;
35
+ className?: string;
36
+ /** true のときヘッダーをドラッグしてウィンドウを移動できる */
37
+ draggable?: boolean;
38
+ /** draggable 時の初期位置(未指定時は { x: 100, y: 100 }) */
39
+ defaultPosition?: { x: number; y: number };
40
+ /** true のとき表示時に Windows 風のスケール+フェードインアニメーションを行う */
41
+ animateOnOpen?: boolean;
42
+ /** 右上の赤ボタンで閉じる際に呼ばれる。指定時は赤ボタンが閉じるボタンになる */
43
+ onClose?: () => void;
44
+ /** 指定時はヘッダーにログアウトボタンを表示(認証連携用) */
45
+ onLogout?: () => void;
46
+ /** true のときボディにパスワード(ログイン)画面を表示(production/staging 用) */
47
+ showLogin?: boolean;
48
+ /** ログイン画面のタイトル */
49
+ loginTitle?: string;
50
+ /** ログイン送信 */
51
+ onLogin?: (password: string) => Promise<void>;
52
+ /** ログイン失敗メッセージ */
53
+ loginError?: string;
54
+ /** ログインエラーをクリア */
55
+ onClearLoginError?: () => void;
56
+ };
57
+
58
+ const CLOSE_ANIMATION_MS = 200;
59
+
60
+ export function LogWindow({
61
+ entries = [],
62
+ networkEntries = [],
63
+ defaultTab = 'logs',
64
+ onTabChange,
65
+ height,
66
+ className = '',
67
+ draggable = false,
68
+ defaultPosition,
69
+ animateOnOpen = false,
70
+ onClose,
71
+ onLogout,
72
+ showLogin = false,
73
+ loginTitle = 'Login',
74
+ onLogin,
75
+ loginError,
76
+ onClearLoginError,
77
+ }: LogWindowProps) {
78
+ const [activeTab, setActiveTab] = useState<LogWindowTab>(defaultTab);
79
+ const [isClosing, setIsClosing] = useState(false);
80
+ const [unreadLogsCount, setUnreadLogsCount] = useState(0);
81
+ const [unreadNetworksCount, setUnreadNetworksCount] = useState(0);
82
+ const [exportDialogOpen, setExportDialogOpen] = useState(false);
83
+ const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
84
+ const prevEntriesLengthRef = useRef(entries.length);
85
+ const prevNetworkEntriesLengthRef = useRef(networkEntries.length);
86
+ const [position, setPosition] = useState<{ x: number; y: number }>(() =>
87
+ draggable ? defaultPosition ?? { x: 100, y: 100 } : { x: 0, y: 0 }
88
+ );
89
+ const dragStartRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null);
90
+
91
+ useEffect(() => {
92
+ const prevLogs = prevEntriesLengthRef.current;
93
+ const prevNetworks = prevNetworkEntriesLengthRef.current;
94
+ if (entries.length > prevLogs && activeTab === 'networks') {
95
+ setUnreadLogsCount((c) => c + (entries.length - prevLogs));
96
+ }
97
+ prevEntriesLengthRef.current = entries.length;
98
+ if (networkEntries.length > prevNetworks && activeTab === 'logs') {
99
+ setUnreadNetworksCount((c) => c + (networkEntries.length - prevNetworks));
100
+ }
101
+ prevNetworkEntriesLengthRef.current = networkEntries.length;
102
+ }, [entries.length, networkEntries.length, activeTab]);
103
+
104
+ const handleTab = (tab: LogWindowTab) => {
105
+ setActiveTab(tab);
106
+ if (tab === 'logs') setUnreadLogsCount(0);
107
+ if (tab === 'networks') setUnreadNetworksCount(0);
108
+ onTabChange?.(tab);
109
+ };
110
+
111
+ const handleHeaderMouseDown = useCallback(
112
+ (e: React.MouseEvent) => {
113
+ if (!draggable || e.button !== 0) return;
114
+ e.preventDefault();
115
+ dragStartRef.current = {
116
+ x: position.x,
117
+ y: position.y,
118
+ clientX: e.clientX,
119
+ clientY: e.clientY,
120
+ };
121
+ },
122
+ [draggable, position]
123
+ );
124
+
125
+ useEffect(() => {
126
+ if (!draggable) return;
127
+
128
+ const handleMouseMove = (e: MouseEvent) => {
129
+ if (dragStartRef.current == null) return;
130
+ setPosition({
131
+ x: dragStartRef.current.x + (e.clientX - dragStartRef.current.clientX),
132
+ y: dragStartRef.current.y + (e.clientY - dragStartRef.current.clientY),
133
+ });
134
+ };
135
+
136
+ const handleMouseUp = () => {
137
+ dragStartRef.current = null;
138
+ };
139
+
140
+ document.addEventListener('mousemove', handleMouseMove);
141
+ document.addEventListener('mouseup', handleMouseUp);
142
+ return () => {
143
+ document.removeEventListener('mousemove', handleMouseMove);
144
+ document.removeEventListener('mouseup', handleMouseUp);
145
+ };
146
+ }, [draggable]);
147
+
148
+ useEffect(() => {
149
+ return () => {
150
+ if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
151
+ };
152
+ }, []);
153
+
154
+ const handleCloseClick = useCallback(() => {
155
+ if (!onClose || isClosing) return;
156
+ setIsClosing(true);
157
+ closeTimeoutRef.current = setTimeout(() => {
158
+ closeTimeoutRef.current = null;
159
+ onClose();
160
+ }, CLOSE_ANIMATION_MS);
161
+ }, [onClose, isClosing]);
162
+
163
+ const handleExportCsv = useCallback(() => {
164
+ const header = 'level,message,source\n';
165
+ const rows = entries.map((e) =>
166
+ [e.level, escapeCsvCell(e.message), escapeCsvCell(e.source)].join(',')
167
+ );
168
+ const csv = header + rows.join('\n');
169
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
170
+ downloadBlob(blob, `logs-${Date.now()}.csv`);
171
+ setExportDialogOpen(false);
172
+ }, [entries]);
173
+
174
+ const handleExportJson = useCallback(() => {
175
+ const data = { logs: entries, networks: networkEntries };
176
+ const json = JSON.stringify(data, null, 2);
177
+ const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
178
+ downloadBlob(blob, `logs-${Date.now()}.json`);
179
+ setExportDialogOpen(false);
180
+ }, [entries, networkEntries]);
181
+
182
+ const bodyStyle = height != null ? { maxHeight: typeof height === 'number' ? `${height}px` : height } : undefined;
183
+
184
+ const rootStyle: React.CSSProperties | undefined = draggable
185
+ ? {
186
+ position: 'fixed',
187
+ left: position.x,
188
+ top: position.y,
189
+ zIndex: 10000,
190
+ }
191
+ : undefined;
192
+
193
+ const rootClassName = [
194
+ 'logWindow',
195
+ animateOnOpen && !isClosing ? 'logWindowOpenAnimation' : '',
196
+ isClosing ? 'logWindowCloseAnimation' : '',
197
+ className,
198
+ ]
199
+ .filter(Boolean)
200
+ .join(' ')
201
+ .trim();
202
+
203
+ const innerClassName = [
204
+ 'logWindowInner',
205
+ showLogin && loginError ? 'logWindowLoginError' : '',
206
+ ]
207
+ .filter(Boolean)
208
+ .join(' ')
209
+ .trim();
210
+
211
+ return (
212
+ <div
213
+ className={rootClassName.trim()}
214
+ {...(rootStyle != null ? { style: rootStyle } : {})}
215
+ >
216
+ <div className={innerClassName}>
217
+ <header
218
+ className={`logWindowHeader ${draggable ? 'logWindowHeaderDraggable' : ''} ${showLogin ? 'logWindowHeaderLoginOnly' : ''}`}
219
+ onMouseDown={handleHeaderMouseDown}
220
+ role={draggable ? 'button' : undefined}
221
+ tabIndex={draggable ? 0 : undefined}
222
+ aria-label={draggable ? 'Move window' : undefined}
223
+ >
224
+ {!showLogin && (
225
+ <div className="logWindowTabs">
226
+ <button
227
+ type="button"
228
+ className={`logWindowTab ${activeTab === 'logs' ? 'logWindowTabActive' : ''}`}
229
+ onClick={() => handleTab('logs')}
230
+ onMouseDown={(e) => draggable && e.stopPropagation()}
231
+ aria-pressed={activeTab === 'logs'}
232
+ >
233
+ <Terminal className="logWindowTabIcon logWindowTabIconTerminal" size={14} aria-hidden />
234
+ Logs
235
+ {activeTab !== 'logs' && unreadLogsCount > 0 && (
236
+ <span className="logWindowTabBadge" aria-label={`New logs: ${unreadLogsCount}`}>
237
+ {unreadLogsCount > 99 ? '99+' : unreadLogsCount}
238
+ </span>
239
+ )}
240
+ </button>
241
+ <button
242
+ type="button"
243
+ className={`logWindowTab ${activeTab === 'networks' ? 'logWindowTabActive' : ''}`}
244
+ onClick={() => handleTab('networks')}
245
+ onMouseDown={(e) => draggable && e.stopPropagation()}
246
+ aria-pressed={activeTab === 'networks'}
247
+ >
248
+ <Wifi className="logWindowTabIcon logWindowTabIconWifi" size={14} aria-hidden />
249
+ Networks
250
+ {activeTab !== 'networks' && unreadNetworksCount > 0 && (
251
+ <span className="logWindowTabBadge" aria-label={`New networks: ${unreadNetworksCount}`}>
252
+ {unreadNetworksCount > 99 ? '99+' : unreadNetworksCount}
253
+ </span>
254
+ )}
255
+ </button>
256
+ </div>
257
+ )}
258
+ {showLogin && <span className="logWindowHeaderLoginTitle">{loginTitle}</span>}
259
+ {onLogout && !showLogin && (
260
+ <button
261
+ type="button"
262
+ className="logWindowLogoutButton"
263
+ onClick={onLogout}
264
+ aria-label="Log out"
265
+ title="Log out"
266
+ >
267
+ <LogOut size={14} aria-hidden />
268
+ <span>Log out</span>
269
+ </button>
270
+ )}
271
+ {onClose ? (
272
+ <button
273
+ type="button"
274
+ className="logWindowCloseButton"
275
+ onClick={handleCloseClick}
276
+ disabled={isClosing}
277
+ aria-label="close"
278
+ title="close"
279
+ >
280
+ <X size={16} fill="currentColor" aria-hidden />
281
+ </button>
282
+ ) : (
283
+ <span className="logWindowIndicator" title="Recording / Active">
284
+ <X size={16} fill="currentColor" aria-hidden />
285
+ </span>
286
+ )}
287
+ </header>
288
+ <div className="logWindowBody" style={bodyStyle}>
289
+ {showLogin && onLogin ? (
290
+ <LoginForm
291
+ title={loginTitle}
292
+ onLogin={onLogin}
293
+ errorMessage={loginError}
294
+ onClearError={onClearLoginError}
295
+ />
296
+ ) : (
297
+ <div
298
+ className="logWindowBodySlider"
299
+ style={{
300
+ transform: activeTab === 'logs' ? 'translateX(0)' : 'translateX(-50%)',
301
+ }}
302
+ >
303
+ <div className="logWindowBodyPanel">
304
+ {entries.map((entry) => (
305
+ <LogEntryRow key={entry.id} entry={entry} />
306
+ ))}
307
+ </div>
308
+ <div className="logWindowBodyPanel">
309
+ {networkEntries.length > 0 ? (
310
+ networkEntries.map((entry) => (
311
+ <NetworkEntryRow key={entry.id} entry={entry} />
312
+ ))
313
+ ) : (
314
+ <div className="logWindowEntry logWindowEntryInfo" style={{ color: 'var(--logw-text-muted)' }}>
315
+ Network requests will appear here.
316
+ </div>
317
+ )}
318
+ </div>
319
+ </div>
320
+ )}
321
+ </div>
322
+ {!showLogin && (
323
+ <footer className="logWindowFooter">
324
+ <button
325
+ type="button"
326
+ className="logWindowExportButton"
327
+ onClick={() => setExportDialogOpen(true)}
328
+ aria-haspopup="dialog"
329
+ aria-expanded={exportDialogOpen}
330
+ >
331
+ <Download size={14} aria-hidden />
332
+ Export
333
+ </button>
334
+ </footer>
335
+ )}
336
+ {exportDialogOpen && (
337
+ <div
338
+ className="logWindowExportOverlay"
339
+ role="dialog"
340
+ aria-modal="true"
341
+ aria-labelledby="logWindowExportDialogTitle"
342
+ >
343
+ <div className="logWindowExportDialog">
344
+ <h3 id="logWindowExportDialogTitle" className="logWindowExportDialogTitle">
345
+ Export format
346
+ </h3>
347
+ <p className="logWindowExportDialogDescription">CSV でエクスポートしますか、JSON でエクスポートしますか?</p>
348
+ <div className="logWindowExportDialogActions">
349
+ <button
350
+ type="button"
351
+ className="logWindowExportFormatButton"
352
+ onClick={handleExportCsv}
353
+ >
354
+ <FileText size={18} aria-hidden />
355
+ CSV
356
+ </button>
357
+ <button
358
+ type="button"
359
+ className="logWindowExportFormatButton"
360
+ onClick={handleExportJson}
361
+ >
362
+ <FileJson size={18} aria-hidden />
363
+ JSON
364
+ </button>
365
+ </div>
366
+ <button
367
+ type="button"
368
+ className="logWindowExportDialogCancel"
369
+ onClick={() => setExportDialogOpen(false)}
370
+ >
371
+ Cancel
372
+ </button>
373
+ </div>
374
+ </div>
375
+ )}
376
+ </div>
377
+ </div>
378
+ );
379
+ }