@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.
- package/.storybook/main.ts +21 -0
- package/.storybook/preview.ts +21 -0
- package/dist/components/LogWindow/LogEntryRow.d.ts +8 -0
- package/dist/components/LogWindow/LogEntryRow.d.ts.map +1 -0
- package/dist/components/LogWindow/LogEntryRow.js +14 -0
- package/dist/components/LogWindow/LogWindow.d.ts +41 -0
- package/dist/components/LogWindow/LogWindow.d.ts.map +1 -0
- package/dist/components/LogWindow/LogWindow.js +144 -0
- package/dist/components/LogWindow/LogWindow.stories.d.ts +29 -0
- package/dist/components/LogWindow/LogWindow.stories.d.ts.map +1 -0
- package/dist/components/LogWindow/LogWindow.stories.js +183 -0
- package/dist/components/LogWindow/LogWindow.test.d.ts +2 -0
- package/dist/components/LogWindow/LogWindow.test.d.ts.map +1 -0
- package/dist/components/LogWindow/LogWindow.test.js +61 -0
- package/dist/components/LogWindow/LogWindowEscapeDemo.d.ts +12 -0
- package/dist/components/LogWindow/LogWindowEscapeDemo.d.ts.map +1 -0
- package/dist/components/LogWindow/LogWindowEscapeDemo.js +56 -0
- package/dist/components/LogWindow/NetworkEntryRow.d.ts +8 -0
- package/dist/components/LogWindow/NetworkEntryRow.d.ts.map +1 -0
- package/dist/components/LogWindow/NetworkEntryRow.js +32 -0
- package/dist/components/LogWindow/index.d.ts +7 -0
- package/dist/components/LogWindow/index.d.ts.map +1 -0
- package/dist/components/LogWindow/index.js +4 -0
- package/dist/components/LogWindow/types.d.ts +36 -0
- package/dist/components/LogWindow/types.d.ts.map +1 -0
- package/dist/components/LogWindow/types.js +1 -0
- package/dist/components/LoginWindow/LoginForm.d.ts +11 -0
- package/dist/components/LoginWindow/LoginForm.d.ts.map +1 -0
- package/dist/components/LoginWindow/LoginForm.js +28 -0
- package/dist/components/LoginWindow/LoginForm.test.d.ts +2 -0
- package/dist/components/LoginWindow/LoginForm.test.d.ts.map +1 -0
- package/dist/components/LoginWindow/LoginForm.test.js +34 -0
- package/dist/components/LoginWindow/LoginWindow.d.ts +15 -0
- package/dist/components/LoginWindow/LoginWindow.d.ts.map +1 -0
- package/dist/components/LoginWindow/LoginWindow.js +27 -0
- package/dist/components/LoginWindow/index.d.ts +3 -0
- package/dist/components/LoginWindow/index.d.ts.map +1 -0
- package/dist/components/LoginWindow/index.js +1 -0
- package/dist/contexts/ApiContext.d.ts +35 -0
- package/dist/contexts/ApiContext.d.ts.map +1 -0
- package/dist/contexts/ApiContext.js +82 -0
- package/dist/contexts/ApiContext.test.d.ts +2 -0
- package/dist/contexts/ApiContext.test.d.ts.map +1 -0
- package/dist/contexts/ApiContext.test.js +45 -0
- package/dist/contexts/PrinterContext.d.ts +12 -0
- package/dist/contexts/PrinterContext.d.ts.map +1 -0
- package/dist/contexts/PrinterContext.js +17 -0
- package/dist/contexts/PrinterContext.test.d.ts +2 -0
- package/dist/contexts/PrinterContext.test.d.ts.map +1 -0
- package/dist/contexts/PrinterContext.test.js +19 -0
- package/dist/contexts/YahmanContext.d.ts +69 -0
- package/dist/contexts/YahmanContext.d.ts.map +1 -0
- package/dist/contexts/YahmanContext.js +414 -0
- package/dist/contexts/YahmanContext.stories.d.ts +16 -0
- package/dist/contexts/YahmanContext.stories.d.ts.map +1 -0
- package/dist/contexts/YahmanContext.stories.js +64 -0
- package/dist/contexts/YargramContext.d.ts +69 -0
- package/dist/contexts/YargramContext.d.ts.map +1 -0
- package/dist/contexts/YargramContext.js +414 -0
- package/dist/contexts/YargramContext.stories.d.ts +16 -0
- package/dist/contexts/YargramContext.stories.d.ts.map +1 -0
- package/dist/contexts/YargramContext.stories.js +64 -0
- package/dist/contexts/YargramContext.test.d.ts +2 -0
- package/dist/contexts/YargramContext.test.d.ts.map +1 -0
- package/dist/contexts/YargramContext.test.js +54 -0
- package/dist/hooks/useLogWindowShortcut.d.ts +24 -0
- package/dist/hooks/useLogWindowShortcut.d.ts.map +1 -0
- package/dist/hooks/useLogWindowShortcut.js +61 -0
- package/dist/hooks/useLogWindowShortcut.test.d.ts +2 -0
- package/dist/hooks/useLogWindowShortcut.test.d.ts.map +1 -0
- package/dist/hooks/useLogWindowShortcut.test.js +93 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/test/setup.d.ts +2 -0
- package/dist/test/setup.d.ts.map +1 -0
- package/dist/test/setup.js +1 -0
- package/package.json +49 -0
- package/src/components/LogWindow/LogEntryRow.tsx +38 -0
- package/src/components/LogWindow/LogWindow.css +614 -0
- package/src/components/LogWindow/LogWindow.stories.tsx +206 -0
- package/src/components/LogWindow/LogWindow.test.tsx +68 -0
- package/src/components/LogWindow/LogWindow.tsx +379 -0
- package/src/components/LogWindow/LogWindowEscapeDemo.tsx +100 -0
- package/src/components/LogWindow/NetworkEntryRow.tsx +102 -0
- package/src/components/LogWindow/index.ts +13 -0
- package/src/components/LogWindow/types.ts +40 -0
- package/src/components/LoginWindow/LoginForm.test.tsx +38 -0
- package/src/components/LoginWindow/LoginForm.tsx +78 -0
- package/src/components/LoginWindow/LoginWindow.css +198 -0
- package/src/components/LoginWindow/LoginWindow.tsx +90 -0
- package/src/components/LoginWindow/index.ts +2 -0
- package/src/contexts/ApiContext.test.tsx +68 -0
- package/src/contexts/ApiContext.tsx +155 -0
- package/src/contexts/PrinterContext.test.tsx +37 -0
- package/src/contexts/PrinterContext.tsx +35 -0
- package/src/contexts/YargramContext.stories.tsx +148 -0
- package/src/contexts/YargramContext.test.tsx +105 -0
- package/src/contexts/YargramContext.tsx +676 -0
- package/src/hooks/useLogWindowShortcut.test.ts +111 -0
- package/src/hooks/useLogWindowShortcut.ts +96 -0
- package/src/index.ts +14 -0
- package/src/test/setup.ts +1 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { LogWindow } from './LogWindow';
|
|
4
|
+
import { useLogWindowShortcut } from '../../hooks/useLogWindowShortcut';
|
|
5
|
+
import type { LogEntry, NetworkEntry } from './types';
|
|
6
|
+
import './LogWindow.css';
|
|
7
|
+
|
|
8
|
+
const demoStyles: React.CSSProperties = {
|
|
9
|
+
position: 'relative',
|
|
10
|
+
minHeight: 300,
|
|
11
|
+
padding: 24,
|
|
12
|
+
background: 'var(--logw-bg, #1e1e1e)',
|
|
13
|
+
borderRadius: 8,
|
|
14
|
+
color: 'var(--logw-text, #d4d4d4)',
|
|
15
|
+
fontFamily: 'ui-monospace, monospace',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const overlayStyles: React.CSSProperties = {
|
|
19
|
+
position: 'fixed',
|
|
20
|
+
inset: 0,
|
|
21
|
+
background: 'transparent',
|
|
22
|
+
display: 'flex',
|
|
23
|
+
alignItems: 'center',
|
|
24
|
+
justifyContent: 'center',
|
|
25
|
+
zIndex: 9999,
|
|
26
|
+
padding: 24,
|
|
27
|
+
boxSizing: 'border-box',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const instructionStyles: React.CSSProperties = {
|
|
31
|
+
marginBottom: 16,
|
|
32
|
+
fontSize: 14,
|
|
33
|
+
color: 'var(--logw-text-muted, #858585)',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const countStyles: React.CSSProperties = {
|
|
37
|
+
fontSize: 12,
|
|
38
|
+
marginTop: 8,
|
|
39
|
+
padding: '6px 12px',
|
|
40
|
+
background: 'rgba(255,255,255,0.08)',
|
|
41
|
+
borderRadius: 4,
|
|
42
|
+
display: 'inline-block',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const ESCAPE_COUNT = 5;
|
|
46
|
+
|
|
47
|
+
export type LogWindowEscapeDemoProps = {
|
|
48
|
+
entries?: LogEntry[];
|
|
49
|
+
networkEntries?: NetworkEntry[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Escape を 5 回押すとログウィンドウを表示するデモ。
|
|
54
|
+
* Storybook でテスト用に利用。
|
|
55
|
+
*/
|
|
56
|
+
export function LogWindowEscapeDemo({
|
|
57
|
+
entries = [],
|
|
58
|
+
networkEntries = [],
|
|
59
|
+
}: LogWindowEscapeDemoProps) {
|
|
60
|
+
const { isOpen, close, escapeCount: count } = useLogWindowShortcut({
|
|
61
|
+
escapeCount: ESCAPE_COUNT,
|
|
62
|
+
resetAfterMs: 1500,
|
|
63
|
+
closeOnEscape: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div style={demoStyles}>
|
|
68
|
+
<div style={instructionStyles}>
|
|
69
|
+
<strong>Escape キーを {ESCAPE_COUNT} 回押すとログウィンドウが開きます。</strong>
|
|
70
|
+
</div>
|
|
71
|
+
<div style={countStyles}>
|
|
72
|
+
Escape: {count} / {ESCAPE_COUNT}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{isOpen &&
|
|
76
|
+
typeof document !== 'undefined' &&
|
|
77
|
+
createPortal(
|
|
78
|
+
<div
|
|
79
|
+
style={overlayStyles}
|
|
80
|
+
role="presentation"
|
|
81
|
+
>
|
|
82
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
83
|
+
<LogWindow
|
|
84
|
+
entries={entries}
|
|
85
|
+
networkEntries={networkEntries}
|
|
86
|
+
draggable
|
|
87
|
+
animateOnOpen
|
|
88
|
+
onClose={close}
|
|
89
|
+
defaultPosition={{
|
|
90
|
+
x: typeof window !== 'undefined' ? Math.max(0, (window.innerWidth - 696) / 2) : 100,
|
|
91
|
+
y: typeof window !== 'undefined' ? Math.max(0, (window.innerHeight - 466) / 2) : 100,
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>,
|
|
96
|
+
document.body
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Globe, Database, ChevronRight, ChevronDown } from 'lucide-react';
|
|
3
|
+
import type { NetworkEntry } from './types';
|
|
4
|
+
import './LogWindow.css';
|
|
5
|
+
|
|
6
|
+
type NetworkEntryRowProps = {
|
|
7
|
+
entry: NetworkEntry;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function getStatusColor(status?: number): string {
|
|
11
|
+
if (status == null) return 'var(--logw-text-muted)';
|
|
12
|
+
if (status >= 200 && status < 300) return '#4ec9b0';
|
|
13
|
+
if (status >= 400) return '#f14c4c';
|
|
14
|
+
return 'var(--logw-text-muted)';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function NetworkEntryRow({ entry }: NetworkEntryRowProps) {
|
|
18
|
+
const [expanded, setExpanded] = useState(false);
|
|
19
|
+
const statusColor = getStatusColor(entry.status);
|
|
20
|
+
const statusText = entry.statusText ?? (entry.status != null ? String(entry.status) : '—');
|
|
21
|
+
const hasDetails = Boolean(entry.request ?? entry.response);
|
|
22
|
+
|
|
23
|
+
const handleToggle = () => {
|
|
24
|
+
if (hasDetails) setExpanded((e) => !e);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const header = (
|
|
28
|
+
<div
|
|
29
|
+
className={`logWindowNetworkEntry ${hasDetails ? 'logWindowNetworkEntryClickable' : ''}`}
|
|
30
|
+
onClick={hasDetails ? handleToggle : undefined}
|
|
31
|
+
onKeyDown={
|
|
32
|
+
hasDetails
|
|
33
|
+
? (e) => {
|
|
34
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
handleToggle();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
: undefined
|
|
40
|
+
}
|
|
41
|
+
role={hasDetails ? 'button' : undefined}
|
|
42
|
+
tabIndex={hasDetails ? 0 : undefined}
|
|
43
|
+
aria-expanded={hasDetails ? expanded : undefined}
|
|
44
|
+
>
|
|
45
|
+
<span className="logWindowNetworkEntryChevron">
|
|
46
|
+
{hasDetails ? (
|
|
47
|
+
expanded ? (
|
|
48
|
+
<ChevronDown size={14} aria-hidden />
|
|
49
|
+
) : (
|
|
50
|
+
<ChevronRight size={14} aria-hidden />
|
|
51
|
+
)
|
|
52
|
+
) : null}
|
|
53
|
+
</span>
|
|
54
|
+
<span
|
|
55
|
+
className={`logWindowNetworkEntryIcon ${entry.type === 'graphql' ? 'logWindowNetworkEntryIconGraphql' : ''}`}
|
|
56
|
+
title={entry.type === 'rest' ? 'REST' : 'GraphQL'}
|
|
57
|
+
>
|
|
58
|
+
{entry.type === 'rest' ? <Globe size={12} /> : <Database size={12} />}
|
|
59
|
+
</span>
|
|
60
|
+
{entry.type === 'rest' ? (
|
|
61
|
+
<>
|
|
62
|
+
<span className="logWindowNetworkEntryMethod">{entry.method}</span>
|
|
63
|
+
<span className="logWindowNetworkEntryUrl">{entry.url}</span>
|
|
64
|
+
</>
|
|
65
|
+
) : (
|
|
66
|
+
<span className="logWindowNetworkEntryOperation">
|
|
67
|
+
{entry.operationName ?? '(anonymous)'}
|
|
68
|
+
</span>
|
|
69
|
+
)}
|
|
70
|
+
<span className="logWindowNetworkEntryStatus" style={{ color: statusColor }}>
|
|
71
|
+
{statusText}
|
|
72
|
+
</span>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="logWindowNetworkEntryAccordion">
|
|
78
|
+
{header}
|
|
79
|
+
{hasDetails && (entry.request != null || entry.response != null) && (
|
|
80
|
+
<div
|
|
81
|
+
className={`logWindowNetworkEntryDetailsWrapper ${expanded ? 'logWindowNetworkEntryDetailsWrapperExpanded' : ''}`}
|
|
82
|
+
aria-hidden={!expanded}
|
|
83
|
+
>
|
|
84
|
+
<div className="logWindowNetworkEntryDetails">
|
|
85
|
+
{entry.request != null && (
|
|
86
|
+
<div className="logWindowNetworkEntryDetailSection">
|
|
87
|
+
<div className="logWindowNetworkEntryDetailLabel">Request</div>
|
|
88
|
+
<pre className="logWindowNetworkEntryDetailContent">{entry.request}</pre>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
{entry.response != null && (
|
|
92
|
+
<div className="logWindowNetworkEntryDetailSection">
|
|
93
|
+
<div className="logWindowNetworkEntryDetailLabel">Response</div>
|
|
94
|
+
<pre className="logWindowNetworkEntryDetailContent">{entry.response}</pre>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { LogWindow } from './LogWindow';
|
|
2
|
+
export { LogWindowEscapeDemo } from './LogWindowEscapeDemo';
|
|
3
|
+
export type { LogWindowProps } from './LogWindow';
|
|
4
|
+
export { LogEntryRow } from './LogEntryRow';
|
|
5
|
+
export { NetworkEntryRow } from './NetworkEntryRow';
|
|
6
|
+
export type {
|
|
7
|
+
LogEntry,
|
|
8
|
+
LogLevel,
|
|
9
|
+
LogWindowTab,
|
|
10
|
+
NetworkEntry,
|
|
11
|
+
NetworkEntryRest,
|
|
12
|
+
NetworkEntryGraphql,
|
|
13
|
+
} from './types';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type LogLevel = 'info' | 'warn' | 'error';
|
|
2
|
+
|
|
3
|
+
export type LogEntry = {
|
|
4
|
+
id: string;
|
|
5
|
+
level: LogLevel;
|
|
6
|
+
message: string;
|
|
7
|
+
source: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type LogWindowTab = 'logs' | 'networks';
|
|
11
|
+
|
|
12
|
+
/** REST API のネットワークエントリ */
|
|
13
|
+
export type NetworkEntryRest = {
|
|
14
|
+
id: string;
|
|
15
|
+
type: 'rest';
|
|
16
|
+
method: string;
|
|
17
|
+
url: string;
|
|
18
|
+
status?: number;
|
|
19
|
+
statusText?: string;
|
|
20
|
+
/** アコーディオン展開時の Request 表示用 */
|
|
21
|
+
request?: string;
|
|
22
|
+
/** アコーディオン展開時の Response 表示用 */
|
|
23
|
+
response?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** GraphQL のネットワークエントリ */
|
|
27
|
+
export type NetworkEntryGraphql = {
|
|
28
|
+
id: string;
|
|
29
|
+
type: 'graphql';
|
|
30
|
+
operationName?: string;
|
|
31
|
+
url: string;
|
|
32
|
+
status?: number;
|
|
33
|
+
statusText?: string;
|
|
34
|
+
/** アコーディオン展開時の Request 表示用 */
|
|
35
|
+
request?: string;
|
|
36
|
+
/** アコーディオン展開時の Response 表示用 */
|
|
37
|
+
response?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type NetworkEntry = NetworkEntryRest | NetworkEntryGraphql;
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { LoginForm } from './LoginForm';
|
|
5
|
+
|
|
6
|
+
describe('LoginForm', () => {
|
|
7
|
+
it('renders password input and submit button', () => {
|
|
8
|
+
const onLogin = vi.fn();
|
|
9
|
+
render(<LoginForm onLogin={onLogin} />);
|
|
10
|
+
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByRole('button', { name: /log in/i })).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('submit button is disabled when password is empty', () => {
|
|
15
|
+
const onLogin = vi.fn();
|
|
16
|
+
render(<LoginForm onLogin={onLogin} />);
|
|
17
|
+
expect(screen.getByRole('button', { name: /log in/i })).toBeDisabled();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('calls onLogin with password on submit', async () => {
|
|
21
|
+
const user = userEvent.setup();
|
|
22
|
+
const onLogin = vi.fn().mockResolvedValue(undefined);
|
|
23
|
+
render(<LoginForm onLogin={onLogin} />);
|
|
24
|
+
await user.type(screen.getByPlaceholderText('••••••••'), 'secret');
|
|
25
|
+
await user.click(screen.getByRole('button', { name: /log in/i }));
|
|
26
|
+
expect(onLogin).toHaveBeenCalledWith('secret');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shows custom title when provided', () => {
|
|
30
|
+
render(<LoginForm onLogin={vi.fn()} title="Sign in" />);
|
|
31
|
+
expect(screen.getByText('Sign in')).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('shows custom submit label when provided', () => {
|
|
35
|
+
render(<LoginForm onLogin={vi.fn()} submitLabel="Submit" />);
|
|
36
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import { Lock, LogIn } from 'lucide-react';
|
|
3
|
+
import './LoginWindow.css';
|
|
4
|
+
|
|
5
|
+
export type LoginFormProps = {
|
|
6
|
+
onLogin: (password: string) => Promise<void>;
|
|
7
|
+
title?: string;
|
|
8
|
+
submitLabel?: string;
|
|
9
|
+
errorMessage?: string;
|
|
10
|
+
onClearError?: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** ログイン用フォーム(LogWindow 内などに埋め込む用。オーバーレイなし) */
|
|
14
|
+
export function LoginForm({
|
|
15
|
+
onLogin,
|
|
16
|
+
title = 'Login',
|
|
17
|
+
submitLabel = 'Log in',
|
|
18
|
+
errorMessage,
|
|
19
|
+
onClearError,
|
|
20
|
+
}: LoginFormProps) {
|
|
21
|
+
const [password, setPassword] = useState('');
|
|
22
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
23
|
+
|
|
24
|
+
const handleSubmit = useCallback(
|
|
25
|
+
(e: React.FormEvent) => {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
onClearError?.();
|
|
28
|
+
if (!password) return;
|
|
29
|
+
setIsSubmitting(true);
|
|
30
|
+
onLogin(password)
|
|
31
|
+
.then(() => {
|
|
32
|
+
setPassword('');
|
|
33
|
+
})
|
|
34
|
+
.catch(() => {})
|
|
35
|
+
.finally(() => {
|
|
36
|
+
setIsSubmitting(false);
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
[onLogin, password, onClearError]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="loginFormWrap">
|
|
44
|
+
{title && (
|
|
45
|
+
<div className="loginFormHeader">
|
|
46
|
+
<Lock size={18} className="loginFormHeaderIcon" aria-hidden />
|
|
47
|
+
<h3 className="loginFormTitle">{title}</h3>
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
<form onSubmit={handleSubmit} className="loginWindowForm">
|
|
51
|
+
<label className="loginWindowLabel">
|
|
52
|
+
<Lock size={16} className="loginWindowLabelIcon" aria-hidden />
|
|
53
|
+
<span>Password</span>
|
|
54
|
+
<input
|
|
55
|
+
type="password"
|
|
56
|
+
className="loginWindowInput"
|
|
57
|
+
value={password}
|
|
58
|
+
onChange={(e) => {
|
|
59
|
+
setPassword(e.target.value);
|
|
60
|
+
onClearError?.();
|
|
61
|
+
}}
|
|
62
|
+
autoComplete="current-password"
|
|
63
|
+
placeholder="••••••••"
|
|
64
|
+
disabled={isSubmitting}
|
|
65
|
+
/>
|
|
66
|
+
</label>
|
|
67
|
+
<button
|
|
68
|
+
type="submit"
|
|
69
|
+
className="loginWindowSubmit"
|
|
70
|
+
disabled={isSubmitting || !password}
|
|
71
|
+
>
|
|
72
|
+
<LogIn size={16} aria-hidden />
|
|
73
|
+
{isSubmitting ? '...' : submitLabel}
|
|
74
|
+
</button>
|
|
75
|
+
</form>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/* ログインウィンドウオーバーレイ(全画面・フェード) */
|
|
2
|
+
.loginWindowOverlay {
|
|
3
|
+
position: fixed;
|
|
4
|
+
inset: 0;
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
background: rgba(0, 0, 0, 0.7);
|
|
9
|
+
z-index: 99999;
|
|
10
|
+
animation: loginWindowOverlayFadeIn 0.25s ease-out forwards;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@keyframes loginWindowOverlayFadeIn {
|
|
14
|
+
from {
|
|
15
|
+
opacity: 0;
|
|
16
|
+
}
|
|
17
|
+
to {
|
|
18
|
+
opacity: 1;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.loginWindow {
|
|
23
|
+
--loginw-bg: linear-gradient(135deg, #1e1e1e 0%, #252526 100%);
|
|
24
|
+
--loginw-header-bg: #252526;
|
|
25
|
+
--loginw-border: #3c3c3c;
|
|
26
|
+
--loginw-text: #d4d4d4;
|
|
27
|
+
--loginw-text-muted: #858585;
|
|
28
|
+
--loginw-input-bg: #2d2d2d;
|
|
29
|
+
--loginw-focus: #3794ff;
|
|
30
|
+
--loginw-error-bg: rgba(241, 76, 76, 0.15);
|
|
31
|
+
--loginw-error-text: #f14c4c;
|
|
32
|
+
width: 100%;
|
|
33
|
+
max-width: 380px;
|
|
34
|
+
background: var(--loginw-bg);
|
|
35
|
+
border: 1px solid var(--loginw-border);
|
|
36
|
+
border-radius: 10px;
|
|
37
|
+
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
|
38
|
+
animation: loginWindowFadeIn 0.25s ease-out forwards;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@keyframes loginWindowFadeIn {
|
|
42
|
+
from {
|
|
43
|
+
opacity: 0;
|
|
44
|
+
transform: scale(0.96);
|
|
45
|
+
}
|
|
46
|
+
to {
|
|
47
|
+
opacity: 1;
|
|
48
|
+
transform: scale(1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.loginWindowHeader {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: 10px;
|
|
56
|
+
padding: 20px 24px;
|
|
57
|
+
background: var(--loginw-header-bg);
|
|
58
|
+
border-bottom: 1px solid var(--loginw-border);
|
|
59
|
+
border-radius: 10px 10px 0 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.loginWindowHeaderIcon {
|
|
63
|
+
color: var(--loginw-focus);
|
|
64
|
+
flex-shrink: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.loginWindowTitle {
|
|
68
|
+
margin: 0;
|
|
69
|
+
font-size: 18px;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
color: var(--loginw-text);
|
|
72
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.loginWindowForm {
|
|
76
|
+
padding: 24px;
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
gap: 18px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.loginWindowError {
|
|
83
|
+
padding: 10px 12px;
|
|
84
|
+
font-size: 12px;
|
|
85
|
+
color: var(--loginw-error-text);
|
|
86
|
+
background: var(--loginw-error-bg);
|
|
87
|
+
border-radius: 6px;
|
|
88
|
+
border: 1px solid rgba(241, 76, 76, 0.3);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.loginWindowLabel {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
gap: 6px;
|
|
95
|
+
font-size: 12px;
|
|
96
|
+
color: #fff;
|
|
97
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.loginWindowLabel span {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
gap: 6px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.loginWindowLabelIcon {
|
|
107
|
+
color: var(--loginw-text-muted);
|
|
108
|
+
flex-shrink: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.loginWindowInput {
|
|
112
|
+
width: 100%;
|
|
113
|
+
padding: 10px 12px;
|
|
114
|
+
font-size: 14px;
|
|
115
|
+
font-family: inherit;
|
|
116
|
+
color: var(--loginw-text);
|
|
117
|
+
background: var(--loginw-input-bg);
|
|
118
|
+
border: 1px solid #ffffff8d;
|
|
119
|
+
border-radius: 6px;
|
|
120
|
+
box-sizing: border-box;
|
|
121
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.loginWindowInput::placeholder {
|
|
125
|
+
color: rgb(110, 110, 110);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.loginWindowInput:hover {
|
|
129
|
+
border-color: #505050;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.loginWindowInput:focus {
|
|
133
|
+
outline: none;
|
|
134
|
+
border-color: var(--loginw-focus);
|
|
135
|
+
box-shadow: 0 0 0 2px rgba(55, 148, 255, 0.25);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.loginWindowInput:disabled {
|
|
139
|
+
opacity: 0.7;
|
|
140
|
+
cursor: not-allowed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.loginWindowSubmit {
|
|
144
|
+
display: inline-flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
gap: 8px;
|
|
148
|
+
margin-top: 8px;
|
|
149
|
+
padding: 12px 16px;
|
|
150
|
+
font-size: 14px;
|
|
151
|
+
font-weight: 500;
|
|
152
|
+
font-family: inherit;
|
|
153
|
+
color: #fff;
|
|
154
|
+
background: var(--loginw-focus);
|
|
155
|
+
border: none;
|
|
156
|
+
border-radius: 6px;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
transition: background 0.15s, opacity 0.15s;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.loginWindowSubmit:hover:not(:disabled) {
|
|
162
|
+
background: #2a7fd4;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.loginWindowSubmit:disabled {
|
|
166
|
+
opacity: 0.6;
|
|
167
|
+
cursor: not-allowed;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* LogWindow 内埋め込み用(LoginForm) */
|
|
171
|
+
.loginFormWrap {
|
|
172
|
+
padding: 16px;
|
|
173
|
+
min-height: 200px;
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-direction: column;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.loginFormHeader {
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: 8px;
|
|
182
|
+
margin-bottom: 16px;
|
|
183
|
+
padding-bottom: 12px;
|
|
184
|
+
border-bottom: 1px solid var(--loginw-border, #3c3c3c);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.loginFormHeaderIcon {
|
|
188
|
+
color: var(--loginw-focus, #3794ff);
|
|
189
|
+
flex-shrink: 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.loginFormTitle {
|
|
193
|
+
margin: 0;
|
|
194
|
+
font-size: 14px;
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
color: var(--loginw-text, #d4d4d4);
|
|
197
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
198
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import { Lock, LogIn } from 'lucide-react';
|
|
3
|
+
import './LoginWindow.css';
|
|
4
|
+
|
|
5
|
+
export type LoginWindowProps = {
|
|
6
|
+
/** ログイン送信。成功で resolve、失敗で reject(メッセージは errorMessage で表示) */
|
|
7
|
+
onLogin: (password: string) => Promise<void>;
|
|
8
|
+
/** ウィンドウタイトル */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** 送信ボタンラベル */
|
|
11
|
+
submitLabel?: string;
|
|
12
|
+
/** エラー表示用(onLogin が reject したとき) */
|
|
13
|
+
errorMessage?: string;
|
|
14
|
+
/** エラーをクリアする(入力変更時などに親から渡す) */
|
|
15
|
+
onClearError?: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function LoginWindow({
|
|
19
|
+
onLogin,
|
|
20
|
+
title = 'Login',
|
|
21
|
+
submitLabel = 'Log in',
|
|
22
|
+
errorMessage,
|
|
23
|
+
onClearError,
|
|
24
|
+
}: LoginWindowProps) {
|
|
25
|
+
const [password, setPassword] = useState('');
|
|
26
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
27
|
+
|
|
28
|
+
const handleSubmit = useCallback(
|
|
29
|
+
(e: React.FormEvent) => {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
onClearError?.();
|
|
32
|
+
if (!password) return;
|
|
33
|
+
setIsSubmitting(true);
|
|
34
|
+
onLogin(password)
|
|
35
|
+
.then(() => {
|
|
36
|
+
setPassword('');
|
|
37
|
+
})
|
|
38
|
+
.catch(() => {})
|
|
39
|
+
.finally(() => {
|
|
40
|
+
setIsSubmitting(false);
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
[onLogin, password, onClearError]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="loginWindowOverlay" role="dialog" aria-modal="true" aria-labelledby="loginWindowTitle">
|
|
48
|
+
<div className="loginWindow">
|
|
49
|
+
<div className="loginWindowHeader">
|
|
50
|
+
<Lock size={20} className="loginWindowHeaderIcon" aria-hidden />
|
|
51
|
+
<h2 id="loginWindowTitle" className="loginWindowTitle">
|
|
52
|
+
{title}
|
|
53
|
+
</h2>
|
|
54
|
+
</div>
|
|
55
|
+
<form onSubmit={handleSubmit} className="loginWindowForm">
|
|
56
|
+
{errorMessage && (
|
|
57
|
+
<div className="loginWindowError" role="alert">
|
|
58
|
+
{errorMessage}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
<label className="loginWindowLabel">
|
|
62
|
+
<Lock size={16} className="loginWindowLabelIcon" aria-hidden />
|
|
63
|
+
<span>Password</span>
|
|
64
|
+
<input
|
|
65
|
+
type="password"
|
|
66
|
+
className="loginWindowInput"
|
|
67
|
+
value={password}
|
|
68
|
+
onChange={(e) => {
|
|
69
|
+
setPassword(e.target.value);
|
|
70
|
+
onClearError?.();
|
|
71
|
+
}}
|
|
72
|
+
autoComplete="current-password"
|
|
73
|
+
placeholder="••••••••"
|
|
74
|
+
disabled={isSubmitting}
|
|
75
|
+
autoFocus
|
|
76
|
+
/>
|
|
77
|
+
</label>
|
|
78
|
+
<button
|
|
79
|
+
type="submit"
|
|
80
|
+
className="loginWindowSubmit"
|
|
81
|
+
disabled={isSubmitting || !password}
|
|
82
|
+
>
|
|
83
|
+
<LogIn size={16} aria-hidden />
|
|
84
|
+
{isSubmitting ? '...' : submitLabel}
|
|
85
|
+
</button>
|
|
86
|
+
</form>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|