atomsystem 0.4.1 → 0.5.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/README.md +112 -52
- package/atom.design.md +17 -0
- package/components/blocks/BlockDetailPageContent.tsx +337 -0
- package/components/blocks/BlockViewerCard.tsx +229 -0
- package/components/blocks/BlocksSidebar.tsx +93 -0
- package/components/blocks/CardListBlock.tsx +214 -0
- package/components/blocks/FileExplorerBlock.tsx +203 -0
- package/components/blocks/InteractiveChatBlock.tsx +217 -0
- package/components/blocks/MetricStatsBlock.tsx +150 -0
- package/components/blocks/PricingPlanBlock.tsx +175 -0
- package/components/blocks/RealtimeChartBlock.tsx +242 -0
- package/components/blocks/SliderShowcaseBlock.tsx +136 -0
- package/components/blocks/index.ts +8 -0
- package/components/ui/Container.tsx +45 -0
- package/components/ui/Layout.tsx +220 -0
- package/components/ui/Switch.tsx +39 -0
- package/components/ui/Table.tsx +88 -0
- package/components/ui/Tabs.tsx +58 -0
- package/components/ui/Tree.tsx +121 -0
- package/components/ui/index.ts +6 -0
- package/css/atom-ui.css +41 -716
- package/dist/cli.cjs +633 -41
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +630 -39
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +265 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +265 -3
- package/dist/index.js.map +1 -1
- package/package.json +7 -1
- package/presets/agents.guide.md +49 -0
- package/presets/blocks.md +101 -0
- package/presets/components.md +142 -0
- package/presets/default.design.md +17 -0
- package/tokens/token.json +63 -1
- package/tokens/tokens.json +63 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { Card, Button, Badge } from '@/components/ui';
|
|
5
|
+
import {
|
|
6
|
+
Folder,
|
|
7
|
+
FolderOpen,
|
|
8
|
+
FileCode,
|
|
9
|
+
FileText,
|
|
10
|
+
FileJson,
|
|
11
|
+
ChevronRight,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
Copy,
|
|
14
|
+
Check,
|
|
15
|
+
File,
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
|
|
18
|
+
interface FileNode {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
type: 'folder' | 'file';
|
|
22
|
+
extension?: 'tsx' | 'ts' | 'json' | 'css' | 'svg' | 'md';
|
|
23
|
+
size?: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
content?: string;
|
|
26
|
+
children?: FileNode[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FILE_TREE: FileNode[] = [
|
|
30
|
+
{
|
|
31
|
+
id: 'f-tokens',
|
|
32
|
+
name: 'tokens',
|
|
33
|
+
type: 'folder',
|
|
34
|
+
updatedAt: '2026.11.11',
|
|
35
|
+
children: [
|
|
36
|
+
{
|
|
37
|
+
id: 'f-color',
|
|
38
|
+
name: 'color.json',
|
|
39
|
+
type: 'file',
|
|
40
|
+
extension: 'json',
|
|
41
|
+
size: '1.8 KB',
|
|
42
|
+
updatedAt: '2026.11.11 10:20',
|
|
43
|
+
content: `{\n "color": {\n "primary": { "value": "var(--accent-primary)" },\n "neutral": { "value": "var(--bg-primary)" }\n }\n}`,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'f-space',
|
|
47
|
+
name: 'spacing.json',
|
|
48
|
+
type: 'file',
|
|
49
|
+
extension: 'json',
|
|
50
|
+
size: '1.2 KB',
|
|
51
|
+
updatedAt: '2026.11.11 09:15',
|
|
52
|
+
content: `{\n "spacing": {\n "1": { "value": "4px" },\n "2": { "value": "8px" },\n "4": { "value": "16px" }\n }\n}`,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'f-components',
|
|
58
|
+
name: 'components',
|
|
59
|
+
type: 'folder',
|
|
60
|
+
updatedAt: '2026.11.11',
|
|
61
|
+
children: [
|
|
62
|
+
{
|
|
63
|
+
id: 'f-btn',
|
|
64
|
+
name: 'Button.tsx',
|
|
65
|
+
type: 'file',
|
|
66
|
+
extension: 'tsx',
|
|
67
|
+
size: '3.4 KB',
|
|
68
|
+
updatedAt: '2026.11.11 09:30',
|
|
69
|
+
content: `export function Button({ children, variant = 'primary' }) {\n return (\n <button className="px-4 py-2 rounded-(--radius) bg-(--accent-primary) text-white font-medium">\n {children}\n </button>\n );\n}`,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'f-card',
|
|
73
|
+
name: 'Card.tsx',
|
|
74
|
+
type: 'file',
|
|
75
|
+
extension: 'tsx',
|
|
76
|
+
size: '4.2 KB',
|
|
77
|
+
updatedAt: '2026.11.10 16:20',
|
|
78
|
+
content: `export function Card({ title, children }) {\n return (\n <div className="p-4 rounded-(--radius) border border-(--border-subtle) bg-(--bg-primary)">\n <h3>{title}</h3>\n {children}\n </div>\n );\n}`,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export function FileExplorerBlock() {
|
|
85
|
+
const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({
|
|
86
|
+
'f-tokens': true,
|
|
87
|
+
'f-components': true,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const [selectedFile, setSelectedFile] = useState<FileNode>(
|
|
91
|
+
FILE_TREE[0].children![0]
|
|
92
|
+
);
|
|
93
|
+
const [copied, setCopied] = useState(false);
|
|
94
|
+
|
|
95
|
+
const toggleFolder = (folderId: string) => {
|
|
96
|
+
setOpenFolders((prev) => ({ ...prev, [folderId]: !prev[folderId] }));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const getFileIcon = (ext?: string) => {
|
|
100
|
+
switch (ext) {
|
|
101
|
+
case 'tsx':
|
|
102
|
+
case 'ts':
|
|
103
|
+
return <FileCode className="w-3.5 h-3.5 text-blue-500" />;
|
|
104
|
+
case 'json':
|
|
105
|
+
return <FileJson className="w-3.5 h-3.5 text-amber-500" />;
|
|
106
|
+
case 'md':
|
|
107
|
+
return <FileText className="w-3.5 h-3.5 text-purple-500" />;
|
|
108
|
+
default:
|
|
109
|
+
return <File className="w-3.5 h-3.5 text-(--text-secondary)" />;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleCopy = () => {
|
|
114
|
+
if (selectedFile.content) {
|
|
115
|
+
navigator.clipboard.writeText(selectedFile.content);
|
|
116
|
+
setCopied(true);
|
|
117
|
+
setTimeout(() => setCopied(false), 2000);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Card className="flex flex-col md:flex-row h-[380px] overflow-hidden transition-colors">
|
|
123
|
+
{/* 1. Left Tree Nav */}
|
|
124
|
+
<aside className="w-full md:w-56 border-r border-(--border-subtle) bg-(--bg-secondary)/20 p-3 overflow-y-auto shrink-0 space-y-1 text-xs">
|
|
125
|
+
<div className="text-[11px] font-mono text-(--text-secondary) uppercase tracking-wider px-2 py-1 mb-1">
|
|
126
|
+
Project Files
|
|
127
|
+
</div>
|
|
128
|
+
{FILE_TREE.map((node) => {
|
|
129
|
+
const isOpen = openFolders[node.id];
|
|
130
|
+
return (
|
|
131
|
+
<div key={node.id} className="space-y-0.5">
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => toggleFolder(node.id)}
|
|
134
|
+
className="w-full flex items-center gap-1.5 px-2 py-1 rounded-(--radius-sm) text-(--text-secondary) hover:text-(--text-primary) hover:bg-(--bg-secondary) text-left transition-colors"
|
|
135
|
+
>
|
|
136
|
+
{isOpen ? (
|
|
137
|
+
<ChevronDown className="w-3 h-3 text-(--text-secondary)" />
|
|
138
|
+
) : (
|
|
139
|
+
<ChevronRight className="w-3 h-3 text-(--text-secondary)" />
|
|
140
|
+
)}
|
|
141
|
+
{isOpen ? (
|
|
142
|
+
<FolderOpen className="w-3.5 h-3.5 text-(--accent-primary)" />
|
|
143
|
+
) : (
|
|
144
|
+
<Folder className="w-3.5 h-3.5 text-(--text-secondary)" />
|
|
145
|
+
)}
|
|
146
|
+
<span className="font-medium truncate">{node.name}</span>
|
|
147
|
+
</button>
|
|
148
|
+
|
|
149
|
+
{isOpen && node.children && (
|
|
150
|
+
<div className="pl-4 space-y-0.5 border-l border-(--border-subtle) ml-3">
|
|
151
|
+
{node.children.map((file) => {
|
|
152
|
+
const isSelected = selectedFile?.id === file.id;
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
key={file.id}
|
|
156
|
+
onClick={() => setSelectedFile(file)}
|
|
157
|
+
className={`w-full flex items-center gap-1.5 px-2 py-1 rounded-(--radius-sm) text-left transition-colors ${
|
|
158
|
+
isSelected
|
|
159
|
+
? 'bg-(--accent-primary)/10 text-(--accent-primary) font-semibold'
|
|
160
|
+
: 'text-(--text-secondary) hover:text-(--text-primary) hover:bg-(--bg-secondary)'
|
|
161
|
+
}`}
|
|
162
|
+
>
|
|
163
|
+
{getFileIcon(file.extension)}
|
|
164
|
+
<span className="truncate">{file.name}</span>
|
|
165
|
+
</button>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
})}
|
|
173
|
+
</aside>
|
|
174
|
+
|
|
175
|
+
{/* 2. File Preview & Code Area composed with Button & Card */}
|
|
176
|
+
<main className="flex-1 flex flex-col bg-(--bg-primary) overflow-hidden">
|
|
177
|
+
<div className="px-4 py-2.5 border-b border-(--border-subtle) bg-(--bg-secondary)/30 flex items-center justify-between text-xs">
|
|
178
|
+
<div className="flex items-center gap-2">
|
|
179
|
+
{getFileIcon(selectedFile.extension)}
|
|
180
|
+
<span className="font-mono font-medium text-(--text-primary)">{selectedFile.name}</span>
|
|
181
|
+
<Badge variant="neutral" className="text-xs">
|
|
182
|
+
{selectedFile.size}
|
|
183
|
+
</Badge>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<Button
|
|
187
|
+
variant="outline"
|
|
188
|
+
size="sm"
|
|
189
|
+
onClick={handleCopy}
|
|
190
|
+
className="text-xs py-1 px-2.5 flex items-center gap-1"
|
|
191
|
+
>
|
|
192
|
+
{copied ? <Check className="w-3 h-3 text-emerald-600" /> : <Copy className="w-3 h-3" />}
|
|
193
|
+
<span>{copied ? '복사됨' : '코드 복사'}</span>
|
|
194
|
+
</Button>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="flex-1 p-4 overflow-auto font-mono text-xs leading-relaxed bg-(--bg-secondary)/10">
|
|
198
|
+
<pre className="text-(--text-primary)">{selectedFile.content}</pre>
|
|
199
|
+
</div>
|
|
200
|
+
</main>
|
|
201
|
+
</Card>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
4
|
+
import { Card, Button, Input, Badge } from '@/components/ui';
|
|
5
|
+
import {
|
|
6
|
+
Send,
|
|
7
|
+
Bot,
|
|
8
|
+
User,
|
|
9
|
+
Paperclip,
|
|
10
|
+
Check,
|
|
11
|
+
Plus,
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
interface ChatMessage {
|
|
15
|
+
id: string;
|
|
16
|
+
sender: 'user' | 'assistant';
|
|
17
|
+
text: string;
|
|
18
|
+
timestamp: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const QUICK_PROMPTS = [
|
|
22
|
+
'디자인 토큰 어떻게 설정하나요?',
|
|
23
|
+
'4pt 그리드 스페이싱 규칙',
|
|
24
|
+
'다크모드 컬러 전환',
|
|
25
|
+
'0KB 런타임 CSS 빌드',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function InteractiveChatBlock() {
|
|
29
|
+
const [messages, setMessages] = useState<ChatMessage[]>([
|
|
30
|
+
{
|
|
31
|
+
id: 'm1',
|
|
32
|
+
sender: 'assistant',
|
|
33
|
+
text: '안녕하세요. 디자인 토큰 AI 어시스턴트입니다. 색상 시스템, 스페이싱 규격, 레이아웃에 관해 궁금한 점을 질문해보세요.',
|
|
34
|
+
timestamp: '10:00 AM',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'm2',
|
|
38
|
+
sender: 'user',
|
|
39
|
+
text: '브랜드 primary 색상 토큰을 어떻게 바꾸나요?',
|
|
40
|
+
timestamp: '10:01 AM',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'm3',
|
|
44
|
+
sender: 'assistant',
|
|
45
|
+
text: '우측 상단의 테마 에디터를 열거나, :root의 --atom-primary CSS 변수를 원하는 색상 코드로 지정하면 즉시 모든 컴포넌트에 반영됩니다.',
|
|
46
|
+
timestamp: '10:01 AM',
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const [inputVal, setInputVal] = useState('');
|
|
51
|
+
const [isTyping, setIsTyping] = useState(false);
|
|
52
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (scrollRef.current) {
|
|
56
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
57
|
+
}
|
|
58
|
+
}, [messages, isTyping]);
|
|
59
|
+
|
|
60
|
+
const handleSend = (textToSend?: string) => {
|
|
61
|
+
const text = textToSend || inputVal;
|
|
62
|
+
if (!text.trim()) return;
|
|
63
|
+
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const timeStr = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
66
|
+
|
|
67
|
+
const newMsg: ChatMessage = {
|
|
68
|
+
id: `user-${Date.now()}`,
|
|
69
|
+
sender: 'user',
|
|
70
|
+
text: text,
|
|
71
|
+
timestamp: timeStr,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
setMessages((prev) => [...prev, newMsg]);
|
|
75
|
+
if (!textToSend) setInputVal('');
|
|
76
|
+
setIsTyping(true);
|
|
77
|
+
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
let replyText = `"${text}"에 대한 디자인 토큰 가이드입니다. AtomDesignToken의 W3C DTCG 표준 규격에 따라 모든 변수는 CSS 변수(--atom-*)로 안전하게 컴파일됩니다.`;
|
|
80
|
+
if (text.includes('primary') || text.includes('색상')) {
|
|
81
|
+
replyText = 'Primary 브랜드 토큰은 css/atom-ui.css의 --atom-primary 변수로 선언되어 있으며, Tailwind v4 및 런타임 없는 순수 CSS로 상속됩니다.';
|
|
82
|
+
} else if (text.includes('그리드') || text.includes('스페이싱')) {
|
|
83
|
+
replyText = '스페이싱 토큰은 4px 배수 규격(--atom-space-1: 4px, --atom-space-2: 8px, --atom-space-4: 16px)으로 엄격히 관리됩니다.';
|
|
84
|
+
} else if (text.includes('다크')) {
|
|
85
|
+
replyText = '최상위 html 태그의 data-theme="dark" 속성을 통해 라이트/다크 시맨틱 토큰 매트릭스가 즉시 스위칭됩니다.';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const newBotMsg: ChatMessage = {
|
|
89
|
+
id: `bot-${Date.now()}`,
|
|
90
|
+
sender: 'assistant',
|
|
91
|
+
text: replyText,
|
|
92
|
+
timestamp: timeStr,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
setMessages((prev) => [...prev, newBotMsg]);
|
|
96
|
+
setIsTyping(false);
|
|
97
|
+
}, 800);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Card className="flex flex-col h-[480px] overflow-hidden bg-(--bg-primary) border border-(--border-subtle) transition-colors">
|
|
102
|
+
{/* 1. Chat Header */}
|
|
103
|
+
<div className="px-4 py-3 border-b border-(--border-subtle) flex items-center justify-between">
|
|
104
|
+
<div className="flex items-center gap-2">
|
|
105
|
+
<div className="w-7 h-7 rounded-(--radius-sm) bg-(--bg-secondary) border border-(--border-subtle) text-(--text-primary) flex items-center justify-center font-bold text-xs">
|
|
106
|
+
<Bot className="w-3.5 h-3.5" />
|
|
107
|
+
</div>
|
|
108
|
+
<div>
|
|
109
|
+
<div className="text-xs font-semibold text-(--text-primary)">
|
|
110
|
+
디자인 토큰 어시스턴트
|
|
111
|
+
</div>
|
|
112
|
+
<div className="text-[11px] text-(--text-muted)">온디맨드 응답</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<Button
|
|
117
|
+
variant="outline"
|
|
118
|
+
size="sm"
|
|
119
|
+
onClick={() => setMessages(messages.slice(0, 1))}
|
|
120
|
+
className="text-xs py-1 px-2.5 h-auto"
|
|
121
|
+
>
|
|
122
|
+
대화 초기화
|
|
123
|
+
</Button>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* 2. Messages Scroll Area */}
|
|
127
|
+
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto space-y-3.5 bg-(--bg-primary)">
|
|
128
|
+
{messages.map((m) => {
|
|
129
|
+
const isUser = m.sender === 'user';
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
key={m.id}
|
|
133
|
+
className={`flex items-start gap-2.5 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}
|
|
134
|
+
>
|
|
135
|
+
<div
|
|
136
|
+
className={`w-6 h-6 rounded-(--radius-sm) flex items-center justify-center text-xs shrink-0 ${
|
|
137
|
+
isUser
|
|
138
|
+
? 'bg-(--text-primary) text-(--bg-primary)'
|
|
139
|
+
: 'bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle)'
|
|
140
|
+
}`}
|
|
141
|
+
>
|
|
142
|
+
{isUser ? <User className="w-3 h-3" /> : <Bot className="w-3 h-3" />}
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className={`max-w-[75%] space-y-1 ${isUser ? 'items-end' : 'items-start'}`}>
|
|
146
|
+
<div
|
|
147
|
+
className={`p-3 rounded-(--radius) text-xs leading-relaxed ${
|
|
148
|
+
isUser
|
|
149
|
+
? 'bg-(--text-primary) text-(--bg-primary) font-medium'
|
|
150
|
+
: 'bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle)'
|
|
151
|
+
}`}
|
|
152
|
+
>
|
|
153
|
+
{m.text}
|
|
154
|
+
</div>
|
|
155
|
+
<div className={`text-[11px] text-(--text-muted) px-1 ${isUser ? 'text-right' : 'text-left'}`}>
|
|
156
|
+
{m.timestamp}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
|
|
163
|
+
{isTyping && (
|
|
164
|
+
<div className="flex items-center gap-2 text-xs text-(--text-muted)">
|
|
165
|
+
<Bot className="w-3.5 h-3.5" />
|
|
166
|
+
<span>확인 중...</span>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* 3. Quick Suggestion Pills */}
|
|
172
|
+
<div className="px-3 py-2 border-t border-(--border-subtle) flex items-center gap-1.5 overflow-x-auto scrollbar-none">
|
|
173
|
+
{QUICK_PROMPTS.map((q, idx) => (
|
|
174
|
+
<Button
|
|
175
|
+
key={idx}
|
|
176
|
+
variant="outline"
|
|
177
|
+
size="sm"
|
|
178
|
+
onClick={() => handleSend(q)}
|
|
179
|
+
className="shrink-0 text-xs py-1 px-2.5 h-auto whitespace-nowrap"
|
|
180
|
+
>
|
|
181
|
+
{q}
|
|
182
|
+
</Button>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{/* 4. Bottom Input Bar composed with Input & Button */}
|
|
187
|
+
<div className="p-3 border-t border-(--border-subtle) bg-(--bg-primary)">
|
|
188
|
+
<div className="flex items-center gap-2">
|
|
189
|
+
<input
|
|
190
|
+
type="text"
|
|
191
|
+
value={inputVal}
|
|
192
|
+
onChange={(e) => setInputVal(e.target.value)}
|
|
193
|
+
onKeyDown={(e) => {
|
|
194
|
+
if (e.key === 'Enter') {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
handleSend();
|
|
197
|
+
}
|
|
198
|
+
}}
|
|
199
|
+
placeholder="디자인 시스템이나 토큰 규칙에 관해 질문하세요..."
|
|
200
|
+
className="flex-1 text-xs px-3 py-2 rounded-(--radius-sm) bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle) focus:outline-none focus:border-(--border-strong) transition-colors"
|
|
201
|
+
/>
|
|
202
|
+
<Button
|
|
203
|
+
type="button"
|
|
204
|
+
variant="primary"
|
|
205
|
+
size="sm"
|
|
206
|
+
onClick={() => handleSend()}
|
|
207
|
+
aria-label="전송"
|
|
208
|
+
title="전송"
|
|
209
|
+
className="p-2 h-auto"
|
|
210
|
+
>
|
|
211
|
+
<Send className="w-3.5 h-3.5" />
|
|
212
|
+
</Button>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</Card>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Card, Badge } from '@/components/ui';
|
|
5
|
+
import {
|
|
6
|
+
TrendingUp,
|
|
7
|
+
TrendingDown,
|
|
8
|
+
DollarSign,
|
|
9
|
+
Users,
|
|
10
|
+
Target,
|
|
11
|
+
Server,
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
interface MetricItem {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
subLabel: string;
|
|
18
|
+
value: string;
|
|
19
|
+
change: string;
|
|
20
|
+
isPositive: boolean;
|
|
21
|
+
icon: 'dollar' | 'users' | 'target' | 'server';
|
|
22
|
+
sparkline: number[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const METRICS: MetricItem[] = [
|
|
26
|
+
{
|
|
27
|
+
id: 'm-arr',
|
|
28
|
+
label: '월간 반복 매출 (MRR)',
|
|
29
|
+
subLabel: '구독 플랜 총액',
|
|
30
|
+
value: '₩48,250,000',
|
|
31
|
+
change: '+18.4%',
|
|
32
|
+
isPositive: true,
|
|
33
|
+
icon: 'dollar',
|
|
34
|
+
sparkline: [35, 42, 40, 55, 60, 58, 72, 85],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'm-mau',
|
|
38
|
+
label: '활성 개발자 (MAU)',
|
|
39
|
+
subLabel: '토큰 CLI 활성 호출자',
|
|
40
|
+
value: '49,210명',
|
|
41
|
+
change: '+12.6%',
|
|
42
|
+
isPositive: true,
|
|
43
|
+
icon: 'users',
|
|
44
|
+
sparkline: [20, 25, 22, 38, 45, 48, 55, 64],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'm-cvr',
|
|
48
|
+
label: '유료 플랜 전환율',
|
|
49
|
+
subLabel: '무료에서 Pro 업그레이드',
|
|
50
|
+
value: '4.92%',
|
|
51
|
+
change: '-0.3%',
|
|
52
|
+
isPositive: false,
|
|
53
|
+
icon: 'target',
|
|
54
|
+
sparkline: [60, 58, 62, 55, 50, 52, 48, 45],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'm-uptime',
|
|
58
|
+
label: 'API 가동률 & SLA',
|
|
59
|
+
subLabel: '엣지 컴파일 노드 헬스',
|
|
60
|
+
value: '99.98%',
|
|
61
|
+
change: '+0.02%',
|
|
62
|
+
isPositive: true,
|
|
63
|
+
icon: 'server',
|
|
64
|
+
sparkline: [95, 96, 98, 97, 99, 99, 100, 100],
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export function MetricStatsBlock() {
|
|
69
|
+
const getIcon = (type: MetricItem['icon']) => {
|
|
70
|
+
switch (type) {
|
|
71
|
+
case 'dollar':
|
|
72
|
+
return <DollarSign className="w-3.5 h-3.5 text-(--accent-primary)" />;
|
|
73
|
+
case 'users':
|
|
74
|
+
return <Users className="w-3.5 h-3.5 text-blue-500" />;
|
|
75
|
+
case 'target':
|
|
76
|
+
return <Target className="w-3.5 h-3.5 text-amber-500" />;
|
|
77
|
+
case 'server':
|
|
78
|
+
return <Server className="w-3.5 h-3.5 text-emerald-500" />;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Card className="overflow-hidden transition-colors">
|
|
84
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 divide-y sm:divide-y-0 sm:divide-x divide-(--border-subtle)">
|
|
85
|
+
{METRICS.map((m) => {
|
|
86
|
+
const maxVal = Math.max(...m.sparkline);
|
|
87
|
+
const minVal = Math.min(...m.sparkline);
|
|
88
|
+
const range = maxVal - minVal || 1;
|
|
89
|
+
|
|
90
|
+
const points = m.sparkline
|
|
91
|
+
.map((val, idx) => {
|
|
92
|
+
const x = (idx / (m.sparkline.length - 1)) * 70;
|
|
93
|
+
const y = 24 - ((val - minVal) / range) * 20;
|
|
94
|
+
return `${x},${y}`;
|
|
95
|
+
})
|
|
96
|
+
.join(' ');
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div key={m.id} className="p-4 sm:p-5 flex flex-col justify-between space-y-3">
|
|
100
|
+
<div className="flex items-center justify-between">
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<div className="p-1.5 rounded-(--radius-sm) bg-(--bg-secondary) border border-(--border-subtle)">
|
|
103
|
+
{getIcon(m.icon)}
|
|
104
|
+
</div>
|
|
105
|
+
<span className="text-xs text-(--text-secondary) font-medium">{m.label}</span>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div className="flex items-baseline justify-between">
|
|
110
|
+
<div>
|
|
111
|
+
<div className="text-xl font-bold tracking-tight text-(--text-primary)">
|
|
112
|
+
{m.value}
|
|
113
|
+
</div>
|
|
114
|
+
<div className="text-[11px] text-(--text-secondary) mt-0.5">{m.subLabel}</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Mini SVG Sparkline */}
|
|
118
|
+
<div className="w-18 h-7">
|
|
119
|
+
<svg className="w-full h-full overflow-visible" viewBox="0 0 70 24">
|
|
120
|
+
<polyline
|
|
121
|
+
fill="none"
|
|
122
|
+
stroke="currentColor"
|
|
123
|
+
strokeWidth="1.5"
|
|
124
|
+
strokeLinecap="round"
|
|
125
|
+
strokeLinejoin="round"
|
|
126
|
+
points={points}
|
|
127
|
+
className={m.isPositive ? 'text-emerald-500' : 'text-rose-500'}
|
|
128
|
+
/>
|
|
129
|
+
</svg>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div className="pt-2 border-t border-(--border-subtle) flex items-center justify-between text-[11px]">
|
|
134
|
+
<span className="text-(--text-secondary)">전월 동기 대비</span>
|
|
135
|
+
<span
|
|
136
|
+
className={`font-semibold flex items-center gap-0.5 ${
|
|
137
|
+
m.isPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'
|
|
138
|
+
}`}
|
|
139
|
+
>
|
|
140
|
+
{m.isPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
|
141
|
+
<span>{m.change}</span>
|
|
142
|
+
</span>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</div>
|
|
148
|
+
</Card>
|
|
149
|
+
);
|
|
150
|
+
}
|