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,229 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Code2,
|
|
6
|
+
Eye,
|
|
7
|
+
Copy,
|
|
8
|
+
Check,
|
|
9
|
+
Monitor,
|
|
10
|
+
Tablet,
|
|
11
|
+
Smartphone,
|
|
12
|
+
Terminal,
|
|
13
|
+
} from 'lucide-react';
|
|
14
|
+
import { BlockItem } from '@/lib/data/blocks';
|
|
15
|
+
import { CodeHighlight } from '@/components/common/CodeHighlight';
|
|
16
|
+
|
|
17
|
+
interface BlockViewerCardProps {
|
|
18
|
+
block: BlockItem;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function BlockViewerCard({ block, children }: BlockViewerCardProps) {
|
|
23
|
+
const [activeTab, setActiveTab] = useState<'preview' | 'react' | 'html'>('preview');
|
|
24
|
+
const [viewport, setViewport] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
|
|
25
|
+
const [copiedCode, setCopiedCode] = useState(false);
|
|
26
|
+
const [copiedCli, setCopiedCli] = useState(false);
|
|
27
|
+
|
|
28
|
+
const cliCommand = `npx atomsystem add ${block.id}`;
|
|
29
|
+
|
|
30
|
+
const handleCopyCode = (code: string) => {
|
|
31
|
+
navigator.clipboard.writeText(code);
|
|
32
|
+
setCopiedCode(true);
|
|
33
|
+
setTimeout(() => setCopiedCode(false), 2000);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const handleCopyCli = () => {
|
|
37
|
+
navigator.clipboard.writeText(cliCommand);
|
|
38
|
+
setCopiedCli(true);
|
|
39
|
+
setTimeout(() => setCopiedCli(false), 2000);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const getViewportMaxWidth = () => {
|
|
43
|
+
switch (viewport) {
|
|
44
|
+
case 'mobile':
|
|
45
|
+
return 'max-w-[390px]';
|
|
46
|
+
case 'tablet':
|
|
47
|
+
return 'max-w-[768px]';
|
|
48
|
+
default:
|
|
49
|
+
return 'max-w-full';
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
id={block.id}
|
|
56
|
+
className="scroll-mt-20 rounded-lg bg-(--bg-primary) border border-(--border-subtle) overflow-hidden"
|
|
57
|
+
>
|
|
58
|
+
{/* 1. Shadcn Block Toolbar */}
|
|
59
|
+
<div className="px-5 py-3 border-b border-(--border-subtle) bg-(--bg-secondary)/40 flex flex-wrap items-center justify-between gap-3">
|
|
60
|
+
{/* Left: ID & Title */}
|
|
61
|
+
<div className="flex items-center gap-3">
|
|
62
|
+
<span className="font-mono text-xs font-semibold text-(--text-primary) px-2 py-0.5 rounded border border-(--border-subtle) bg-(--bg-primary)">
|
|
63
|
+
{block.id}
|
|
64
|
+
</span>
|
|
65
|
+
<h2 className="text-sm font-semibold text-(--text-primary)">
|
|
66
|
+
{block.title}
|
|
67
|
+
</h2>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{/* Right Toolbar */}
|
|
71
|
+
<div className="flex items-center gap-2">
|
|
72
|
+
{/* Viewport Resizer */}
|
|
73
|
+
{activeTab === 'preview' && (
|
|
74
|
+
<div className="hidden sm:flex items-center gap-0.5 bg-(--bg-primary) p-0.5 rounded border border-(--border-subtle)">
|
|
75
|
+
<button
|
|
76
|
+
onClick={() => setViewport('desktop')}
|
|
77
|
+
title="Desktop 100%"
|
|
78
|
+
className={`p-1.5 rounded transition-colors ${
|
|
79
|
+
viewport === 'desktop'
|
|
80
|
+
? 'bg-(--bg-secondary) text-(--text-primary)'
|
|
81
|
+
: 'text-(--text-secondary) hover:text-(--text-primary)'
|
|
82
|
+
}`}
|
|
83
|
+
>
|
|
84
|
+
<Monitor className="w-3.5 h-3.5" />
|
|
85
|
+
</button>
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => setViewport('tablet')}
|
|
88
|
+
title="Tablet 768px"
|
|
89
|
+
className={`p-1.5 rounded transition-colors ${
|
|
90
|
+
viewport === 'tablet'
|
|
91
|
+
? 'bg-(--bg-secondary) text-(--text-primary)'
|
|
92
|
+
: 'text-(--text-secondary) hover:text-(--text-primary)'
|
|
93
|
+
}`}
|
|
94
|
+
>
|
|
95
|
+
<Tablet className="w-3.5 h-3.5" />
|
|
96
|
+
</button>
|
|
97
|
+
<button
|
|
98
|
+
onClick={() => setViewport('mobile')}
|
|
99
|
+
title="Mobile 390px"
|
|
100
|
+
className={`p-1.5 rounded transition-colors ${
|
|
101
|
+
viewport === 'mobile'
|
|
102
|
+
? 'bg-(--bg-secondary) text-(--text-primary)'
|
|
103
|
+
: 'text-(--text-secondary) hover:text-(--text-primary)'
|
|
104
|
+
}`}
|
|
105
|
+
>
|
|
106
|
+
<Smartphone className="w-3.5 h-3.5" />
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{/* Preview / Code Tabs */}
|
|
112
|
+
<div className="flex items-center gap-1 bg-(--bg-primary) p-0.5 rounded border border-(--border-subtle)">
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => setActiveTab('preview')}
|
|
115
|
+
data-tab="preview"
|
|
116
|
+
className={`flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium transition-colors ${
|
|
117
|
+
activeTab === 'preview'
|
|
118
|
+
? 'bg-(--bg-secondary) text-(--text-primary) font-semibold'
|
|
119
|
+
: 'text-(--text-secondary) hover:text-(--text-primary)'
|
|
120
|
+
}`}
|
|
121
|
+
>
|
|
122
|
+
<Eye className="w-3.5 h-3.5" />
|
|
123
|
+
<span>Preview</span>
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
onClick={() => setActiveTab('react')}
|
|
127
|
+
data-tab="react"
|
|
128
|
+
className={`flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium transition-colors ${
|
|
129
|
+
activeTab === 'react'
|
|
130
|
+
? 'bg-(--bg-secondary) text-(--text-primary) font-semibold'
|
|
131
|
+
: 'text-(--text-secondary) hover:text-(--text-primary)'
|
|
132
|
+
}`}
|
|
133
|
+
>
|
|
134
|
+
<Code2 className="w-3.5 h-3.5" />
|
|
135
|
+
<span>React</span>
|
|
136
|
+
</button>
|
|
137
|
+
<button
|
|
138
|
+
onClick={() => setActiveTab('html')}
|
|
139
|
+
data-tab="html"
|
|
140
|
+
className={`flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium transition-colors ${
|
|
141
|
+
activeTab === 'html'
|
|
142
|
+
? 'bg-(--bg-secondary) text-(--text-primary) font-semibold'
|
|
143
|
+
: 'text-(--text-secondary) hover:text-(--text-primary)'
|
|
144
|
+
}`}
|
|
145
|
+
>
|
|
146
|
+
<Code2 className="w-3.5 h-3.5" />
|
|
147
|
+
<span>HTML</span>
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* CLI Add Button */}
|
|
152
|
+
<button
|
|
153
|
+
onClick={handleCopyCli}
|
|
154
|
+
title={cliCommand}
|
|
155
|
+
className="hidden lg:flex items-center gap-1 px-2.5 py-1 rounded text-xs font-mono bg-(--bg-primary) hover:bg-(--bg-secondary) text-(--text-secondary) border border-(--border-subtle) transition-colors"
|
|
156
|
+
>
|
|
157
|
+
<Terminal className="w-3 h-3 text-(--text-primary)" />
|
|
158
|
+
<span>{copiedCli ? 'Copied' : cliCommand}</span>
|
|
159
|
+
</button>
|
|
160
|
+
|
|
161
|
+
{/* Copy Code Button */}
|
|
162
|
+
<button
|
|
163
|
+
onClick={() =>
|
|
164
|
+
handleCopyCode(
|
|
165
|
+
activeTab === 'html' ? block.codeSnippets.htmlCss : block.codeSnippets.react
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
className="flex items-center gap-1 px-3 py-1 rounded text-xs font-medium bg-(--bg-primary) hover:bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle) transition-colors"
|
|
169
|
+
>
|
|
170
|
+
{copiedCode ? (
|
|
171
|
+
<>
|
|
172
|
+
<Check className="w-3.5 h-3.5 text-emerald-600" />
|
|
173
|
+
<span className="text-emerald-600 font-semibold">Copied</span>
|
|
174
|
+
</>
|
|
175
|
+
) : (
|
|
176
|
+
<>
|
|
177
|
+
<Copy className="w-3.5 h-3.5 text-(--text-secondary)" />
|
|
178
|
+
<span>Copy Code</span>
|
|
179
|
+
</>
|
|
180
|
+
)}
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{/* 2. Main Viewport */}
|
|
186
|
+
<div className="p-4 sm:p-6 bg-(--bg-secondary)/30 flex justify-center items-center">
|
|
187
|
+
{activeTab === 'preview' ? (
|
|
188
|
+
<div
|
|
189
|
+
className={`w-full transition-all duration-300 mx-auto ${getViewportMaxWidth()} ${
|
|
190
|
+
viewport !== 'desktop'
|
|
191
|
+
? 'border border-(--border-strong) rounded-lg p-2 bg-(--bg-primary) shadow-sm'
|
|
192
|
+
: ''
|
|
193
|
+
}`}
|
|
194
|
+
>
|
|
195
|
+
{children}
|
|
196
|
+
</div>
|
|
197
|
+
) : (
|
|
198
|
+
<div className="w-full bg-(--bg-primary) rounded-md border border-(--border-subtle) overflow-hidden">
|
|
199
|
+
<div className="px-4 py-2 border-b border-(--border-subtle) bg-(--bg-secondary)/50 text-xs font-mono text-(--text-secondary) flex justify-between items-center">
|
|
200
|
+
<span>{activeTab === 'react' ? `${block.id}.tsx` : `${block.id}.html`}</span>
|
|
201
|
+
<span>{activeTab === 'react' ? 'React + Tailwind TSX' : 'HTML / Tailwind'}</span>
|
|
202
|
+
</div>
|
|
203
|
+
<CodeHighlight
|
|
204
|
+
code={activeTab === 'react' ? block.codeSnippets.react : block.codeSnippets.htmlCss}
|
|
205
|
+
language={activeTab === 'react' ? 'tsx' : 'html'}
|
|
206
|
+
className="p-4 max-h-[480px]"
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* 3. Block Meta Footer */}
|
|
213
|
+
<div className="px-5 py-2.5 bg-(--bg-primary) border-t border-(--border-subtle) flex flex-wrap items-center justify-between gap-2 text-xs text-(--text-secondary)">
|
|
214
|
+
<p className="line-clamp-1 max-w-xl">{block.description}</p>
|
|
215
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
216
|
+
<span className="text-(--text-muted)">Tokens:</span>
|
|
217
|
+
{block.tokensUsed.map((token) => (
|
|
218
|
+
<span
|
|
219
|
+
key={token}
|
|
220
|
+
className="font-mono text-xs px-1.5 py-0.5 rounded bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle)"
|
|
221
|
+
>
|
|
222
|
+
{token}
|
|
223
|
+
</span>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useThemeEditor } from '@/components/editor/ThemeEditorProvider';
|
|
6
|
+
import { BLOCKS_COLLECTION, BLOCK_CATEGORIES, BlockItem } from '@/lib/data/blocks';
|
|
7
|
+
import { Search, Layers, ChevronRight } from 'lucide-react';
|
|
8
|
+
|
|
9
|
+
interface BlocksSidebarProps {
|
|
10
|
+
activeId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function BlocksSidebar({ activeId }: BlocksSidebarProps) {
|
|
14
|
+
const { theme } = useThemeEditor();
|
|
15
|
+
const brandColor = theme?.color?.primary || 'var(--accent-primary)';
|
|
16
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
17
|
+
|
|
18
|
+
const filteredBlocks = BLOCKS_COLLECTION.filter((block) => {
|
|
19
|
+
if (!searchQuery.trim()) return true;
|
|
20
|
+
const q = searchQuery.toLowerCase();
|
|
21
|
+
return (
|
|
22
|
+
block.title.toLowerCase().includes(q) ||
|
|
23
|
+
block.englishTitle.toLowerCase().includes(q) ||
|
|
24
|
+
block.id.toLowerCase().includes(q)
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<aside className="w-64 border-r border-(--border-subtle) bg-(--bg-primary) p-4 flex flex-col shrink-0 h-full overflow-y-auto">
|
|
30
|
+
{/* 1. Header & Search */}
|
|
31
|
+
<div className="mb-4 space-y-2">
|
|
32
|
+
<div className="flex items-center justify-between">
|
|
33
|
+
<Link
|
|
34
|
+
href="/blocks"
|
|
35
|
+
className="flex items-center gap-1.5 text-xs font-semibold text-(--text-primary) hover:text-(--accent-primary) transition-colors"
|
|
36
|
+
>
|
|
37
|
+
<Layers className="w-3.5 h-3.5" />
|
|
38
|
+
<span>디자인 블록 카탈로그</span>
|
|
39
|
+
</Link>
|
|
40
|
+
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded-(--radius-sm) bg-(--bg-secondary) text-(--text-secondary) border border-(--border-subtle)">
|
|
41
|
+
{BLOCKS_COLLECTION.length}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div className="relative">
|
|
46
|
+
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-(--text-secondary)" />
|
|
47
|
+
<input
|
|
48
|
+
type="text"
|
|
49
|
+
value={searchQuery}
|
|
50
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
51
|
+
placeholder="블록 검색..."
|
|
52
|
+
className="w-full text-xs pl-7 pr-2.5 py-1.5 rounded-(--radius-sm) bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle) focus:outline-none focus:border-(--border-strong) transition-colors"
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{/* 2. Block List by Category */}
|
|
58
|
+
<div className="space-y-4 text-xs">
|
|
59
|
+
{BLOCK_CATEGORIES.filter((cat) => cat.id !== 'all').map((cat) => {
|
|
60
|
+
const categoryBlocks = filteredBlocks.filter((b) => b.category === cat.id);
|
|
61
|
+
if (categoryBlocks.length === 0) return null;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div key={cat.id} className="space-y-1">
|
|
65
|
+
<div className="text-[11px] font-mono font-medium text-(--text-secondary) uppercase tracking-wider px-2 py-0.5">
|
|
66
|
+
{cat.name}
|
|
67
|
+
</div>
|
|
68
|
+
<div className="space-y-0.5">
|
|
69
|
+
{categoryBlocks.map((block) => {
|
|
70
|
+
const isActive = activeId === block.id;
|
|
71
|
+
return (
|
|
72
|
+
<Link
|
|
73
|
+
key={block.id}
|
|
74
|
+
href={`/blocks/${block.id}`}
|
|
75
|
+
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-(--radius-sm) text-xs transition-colors ${
|
|
76
|
+
isActive
|
|
77
|
+
? 'bg-(--text-primary) text-(--bg-primary) font-medium'
|
|
78
|
+
: 'text-(--text-secondary) hover:text-(--text-primary) hover:bg-(--bg-secondary)'
|
|
79
|
+
}`}
|
|
80
|
+
>
|
|
81
|
+
<span className="truncate">{block.title}</span>
|
|
82
|
+
<ChevronRight className={`w-3 h-3 shrink-0 ${isActive ? 'text-(--bg-primary)' : 'opacity-30'}`} />
|
|
83
|
+
</Link>
|
|
84
|
+
);
|
|
85
|
+
})}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
</aside>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { Card, Button, Badge } from '@/components/ui';
|
|
5
|
+
import {
|
|
6
|
+
LayoutGrid,
|
|
7
|
+
List as ListIcon,
|
|
8
|
+
Search,
|
|
9
|
+
Bookmark,
|
|
10
|
+
ArrowUpRight,
|
|
11
|
+
Palette,
|
|
12
|
+
Layers,
|
|
13
|
+
Shield,
|
|
14
|
+
Zap,
|
|
15
|
+
} from 'lucide-react';
|
|
16
|
+
|
|
17
|
+
interface CardItem {
|
|
18
|
+
id: string;
|
|
19
|
+
category: 'Foundation' | 'Component' | 'Pattern';
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
tag: string;
|
|
23
|
+
price: string;
|
|
24
|
+
icon: 'palette' | 'layers' | 'shield' | 'zap';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const INITIAL_CARDS: CardItem[] = [
|
|
28
|
+
{
|
|
29
|
+
id: 'c1',
|
|
30
|
+
category: 'Foundation',
|
|
31
|
+
title: '시맨틱 색상 & 다크모드 매트릭스',
|
|
32
|
+
description: 'W3C DTCG 표준 3단계 계층 구조를 준수하는 토큰 기반 컬러 시스템.',
|
|
33
|
+
tag: 'W3C 표준',
|
|
34
|
+
price: 'Free',
|
|
35
|
+
icon: 'palette',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'c2',
|
|
39
|
+
category: 'Component',
|
|
40
|
+
title: '고접근성 인터랙티브 모달 & 드로어',
|
|
41
|
+
description: '포커스 트랩과 키보드 내비게이션을 보장하는 시맨틱 레이어 컴포넌트.',
|
|
42
|
+
tag: 'A11y AAA',
|
|
43
|
+
price: '$19',
|
|
44
|
+
icon: 'layers',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'c3',
|
|
48
|
+
category: 'Pattern',
|
|
49
|
+
title: 'B2B 핀테크 인증 & 결제 승인 플로우',
|
|
50
|
+
description: '실시간 유효성 검증과 가맹점 수수료율 자동 산출 반응형 위젯.',
|
|
51
|
+
tag: 'Fintech UX',
|
|
52
|
+
price: '$39',
|
|
53
|
+
icon: 'shield',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'c4',
|
|
57
|
+
category: 'Component',
|
|
58
|
+
title: '가변 폰트 & 타이포 스케일러',
|
|
59
|
+
description: 'Fluid 폰트 크기 및 행간을 토큰으로 제어하는 타이포그래피 블록.',
|
|
60
|
+
tag: 'Typography',
|
|
61
|
+
price: 'Free',
|
|
62
|
+
icon: 'zap',
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export function CardListBlock() {
|
|
67
|
+
const [cards, setCards] = useState<CardItem[]>(INITIAL_CARDS);
|
|
68
|
+
const [selectedCategory, setSelectedCategory] = useState<string>('All');
|
|
69
|
+
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
|
70
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
71
|
+
|
|
72
|
+
const filteredCards = cards.filter((c) => {
|
|
73
|
+
const matchesCat = selectedCategory === 'All' || c.category === selectedCategory;
|
|
74
|
+
const matchesSearch =
|
|
75
|
+
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
76
|
+
c.description.toLowerCase().includes(searchQuery.toLowerCase());
|
|
77
|
+
return matchesCat && matchesSearch;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const getIcon = (type: CardItem['icon']) => {
|
|
81
|
+
switch (type) {
|
|
82
|
+
case 'palette':
|
|
83
|
+
return <Palette className="w-4 h-4 text-purple-500" />;
|
|
84
|
+
case 'layers':
|
|
85
|
+
return <Layers className="w-4 h-4 text-blue-500" />;
|
|
86
|
+
case 'shield':
|
|
87
|
+
return <Shield className="w-4 h-4 text-emerald-500" />;
|
|
88
|
+
case 'zap':
|
|
89
|
+
return <Zap className="w-4 h-4 text-amber-500" />;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Card className="overflow-hidden transition-colors">
|
|
95
|
+
{/* 1. Filter Toolbar composed with Button components */}
|
|
96
|
+
<div className="p-4 border-b border-(--border-subtle) bg-(--bg-secondary)/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
97
|
+
{/* Category Pills */}
|
|
98
|
+
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none">
|
|
99
|
+
{['All', 'Foundation', 'Component', 'Pattern'].map((cat) => (
|
|
100
|
+
<Button
|
|
101
|
+
key={cat}
|
|
102
|
+
variant={selectedCategory === cat ? 'primary' : 'ghost'}
|
|
103
|
+
size="sm"
|
|
104
|
+
onClick={() => setSelectedCategory(cat)}
|
|
105
|
+
className="text-xs py-1 px-2.5 whitespace-nowrap"
|
|
106
|
+
>
|
|
107
|
+
{cat === 'All' ? '전체' : cat}
|
|
108
|
+
</Button>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* View mode & Search */}
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<div className="relative">
|
|
115
|
+
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-(--text-secondary)" />
|
|
116
|
+
<input
|
|
117
|
+
type="text"
|
|
118
|
+
value={searchQuery}
|
|
119
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
120
|
+
placeholder="카드 필터..."
|
|
121
|
+
className="text-xs pl-7 pr-2.5 py-1 rounded-(--radius-sm) bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle) focus:outline-none"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="flex rounded-(--radius-sm) border border-(--border-subtle) p-0.5 bg-(--bg-secondary)">
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => setViewMode('grid')}
|
|
128
|
+
aria-label="Grid"
|
|
129
|
+
className={`p-1 rounded-(--radius-sm) transition-colors ${
|
|
130
|
+
viewMode === 'grid' ? 'bg-(--bg-primary) text-(--text-primary) shadow-xs' : 'text-(--text-secondary)'
|
|
131
|
+
}`}
|
|
132
|
+
>
|
|
133
|
+
<LayoutGrid className="w-3.5 h-3.5" />
|
|
134
|
+
</button>
|
|
135
|
+
<button
|
|
136
|
+
onClick={() => setViewMode('list')}
|
|
137
|
+
aria-label="List"
|
|
138
|
+
className={`p-1 rounded-(--radius-sm) transition-colors ${
|
|
139
|
+
viewMode === 'list' ? 'bg-(--bg-primary) text-(--text-primary) shadow-xs' : 'text-(--text-secondary)'
|
|
140
|
+
}`}
|
|
141
|
+
>
|
|
142
|
+
<ListIcon className="w-3.5 h-3.5" />
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* 2. Cards Viewport composed with UI Card & Badge */}
|
|
149
|
+
<div className="p-4 sm:p-5">
|
|
150
|
+
{viewMode === 'grid' ? (
|
|
151
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
|
|
152
|
+
{filteredCards.map((c) => (
|
|
153
|
+
<Card
|
|
154
|
+
key={c.id}
|
|
155
|
+
interactive
|
|
156
|
+
className="p-4 flex flex-col justify-between"
|
|
157
|
+
>
|
|
158
|
+
<div>
|
|
159
|
+
<div className="flex items-center justify-between mb-2.5">
|
|
160
|
+
<div className="p-2 rounded-(--radius-sm) bg-(--bg-secondary) border border-(--border-subtle)">
|
|
161
|
+
{getIcon(c.icon)}
|
|
162
|
+
</div>
|
|
163
|
+
<Badge variant="neutral" className="text-[11px]">
|
|
164
|
+
{c.tag}
|
|
165
|
+
</Badge>
|
|
166
|
+
</div>
|
|
167
|
+
<h4 className="font-semibold text-xs text-(--text-primary) line-clamp-1 mb-1">
|
|
168
|
+
{c.title}
|
|
169
|
+
</h4>
|
|
170
|
+
<p className="text-xs text-(--text-secondary) line-clamp-2 leading-relaxed">
|
|
171
|
+
{c.description}
|
|
172
|
+
</p>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div className="pt-3 border-t border-(--border-subtle) flex items-center justify-between mt-3 text-xs">
|
|
176
|
+
<span className="font-semibold text-(--text-primary)">{c.price}</span>
|
|
177
|
+
<button className="text-xs font-medium text-(--accent-primary) hover:underline flex items-center gap-0.5">
|
|
178
|
+
<span>선택</span>
|
|
179
|
+
<ArrowUpRight className="w-3 h-3" />
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</Card>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
) : (
|
|
186
|
+
<div className="space-y-2">
|
|
187
|
+
{filteredCards.map((c) => (
|
|
188
|
+
<Card
|
|
189
|
+
key={c.id}
|
|
190
|
+
className="p-3 px-4 flex items-center justify-between transition-colors gap-3"
|
|
191
|
+
>
|
|
192
|
+
<div className="flex items-center gap-3">
|
|
193
|
+
<div className="p-1.5 rounded-(--radius-sm) bg-(--bg-secondary) border border-(--border-subtle)">
|
|
194
|
+
{getIcon(c.icon)}
|
|
195
|
+
</div>
|
|
196
|
+
<div>
|
|
197
|
+
<div className="font-semibold text-xs text-(--text-primary)">{c.title}</div>
|
|
198
|
+
<div className="text-[11px] text-(--text-secondary) line-clamp-1">{c.description}</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
<div className="flex items-center gap-3 shrink-0 text-xs">
|
|
202
|
+
<span className="font-semibold text-(--text-primary)">{c.price}</span>
|
|
203
|
+
<Button variant="secondary" size="sm" className="text-xs py-1 px-2.5">
|
|
204
|
+
상세보기
|
|
205
|
+
</Button>
|
|
206
|
+
</div>
|
|
207
|
+
</Card>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
</Card>
|
|
213
|
+
);
|
|
214
|
+
}
|