@conscience-technology/ctds 0.1.8 → 0.1.10
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/assets/components/AIMessage/AIMessage.module.css +117 -0
- package/assets/components/AIMessage/AIMessage.tsx +90 -0
- package/assets/components/AIMessage/index.ts +2 -0
- package/assets/components/AttachmentChip/AttachmentChip.module.css +3 -1
- package/assets/components/AttachmentThumb/AttachmentThumb.module.css +13 -7
- package/assets/components/AttachmentThumb/AttachmentThumb.tsx +4 -1
- package/assets/components/Bubble/Bubble.module.css +65 -0
- package/assets/components/Bubble/Bubble.tsx +63 -0
- package/assets/components/Bubble/index.ts +2 -0
- package/assets/components/Button/Button.module.css +15 -2
- package/assets/components/Button/Button.tsx +13 -0
- package/assets/components/Calendar/Calendar.module.css +163 -0
- package/assets/components/Calendar/Calendar.tsx +323 -0
- package/assets/components/Calendar/index.ts +2 -0
- package/assets/components/ChatInput/ChatInput.module.css +81 -41
- package/assets/components/ChatInput/ChatInput.tsx +45 -32
- package/assets/components/ChatInput/ModelSelector.tsx +23 -27
- package/assets/components/ChatInput/index.ts +0 -1
- package/assets/components/CodeBlock/CodeBlock.module.css +79 -0
- package/assets/components/CodeBlock/CodeBlock.tsx +71 -0
- package/assets/components/CodeBlock/index.ts +2 -0
- package/assets/components/ContextMenu/ContextMenu.tsx +10 -1
- package/assets/components/DropdownMenu/DropdownMenu.module.css +26 -0
- package/assets/components/DropdownMenu/DropdownMenu.tsx +367 -0
- package/assets/components/DropdownMenu/index.ts +9 -0
- package/assets/components/EventChip/EventChip.module.css +57 -0
- package/assets/components/EventChip/EventChip.tsx +41 -0
- package/assets/components/EventChip/index.ts +2 -0
- package/assets/components/Icon/Icon.tsx +3 -0
- package/assets/components/IdentityButton/IdentityButton.module.css +119 -0
- package/assets/components/IdentityButton/IdentityButton.tsx +113 -0
- package/assets/components/IdentityButton/index.ts +6 -0
- package/assets/components/MenuItem/MenuItem.module.css +67 -2
- package/assets/components/MenuItem/MenuItem.tsx +22 -2
- package/assets/components/MessageActions/MessageActions.module.css +43 -0
- package/assets/components/MessageActions/MessageActions.tsx +94 -0
- package/assets/components/MessageActions/index.ts +6 -0
- package/assets/components/MessageQuote/MessageQuote.module.css +18 -0
- package/assets/components/MessageQuote/MessageQuote.tsx +33 -0
- package/assets/components/MessageQuote/index.ts +2 -0
- package/assets/components/MessageSources/MessageSources.module.css +63 -0
- package/assets/components/MessageSources/MessageSources.tsx +50 -0
- package/assets/components/MessageSources/index.ts +2 -0
- package/assets/components/MessageTable/MessageTable.module.css +46 -0
- package/assets/components/MessageTable/MessageTable.tsx +21 -0
- package/assets/components/MessageTable/index.ts +2 -0
- package/assets/components/QuotationBar/QuotationBar.module.css +2 -2
- package/assets/components/Sidebar/Sidebar.module.css +3 -57
- package/assets/components/Sidebar/Sidebar.tsx +18 -21
- package/assets/components/Spinner/LottiePlayer.tsx +75 -0
- package/assets/components/Spinner/Spinner.module.css +167 -0
- package/assets/components/Spinner/Spinner.tsx +119 -0
- package/assets/components/Spinner/index.ts +10 -0
- package/assets/components/Spinner/lottieColor.ts +76 -0
- package/assets/components/Spinner/lottieManifest.ts +99 -0
- package/assets/components/Tabs/Tabs.module.css +3 -3
- package/assets/components/UserMessage/UserMessage.module.css +41 -0
- package/assets/components/UserMessage/UserMessage.tsx +66 -0
- package/assets/components/UserMessage/index.ts +2 -0
- package/assets/components/_data.ts +18 -4
- package/assets/components/tokens.css +20 -0
- package/assets/prompts/components/ai-message.md +43 -0
- package/assets/prompts/components/bubble.md +56 -0
- package/assets/prompts/components/calendar.md +123 -0
- package/assets/prompts/components/code-block.md +35 -0
- package/assets/prompts/components/dropdown-menu.md +145 -0
- package/assets/prompts/components/identity-button.md +168 -0
- package/assets/prompts/components/menu-item.md +2 -1
- package/assets/prompts/components/message-actions.md +49 -0
- package/assets/prompts/components/message-quote.md +26 -0
- package/assets/prompts/components/message-sources.md +26 -0
- package/assets/prompts/components/message-table.md +27 -0
- package/assets/prompts/components/sidebar.md +106 -37
- package/assets/prompts/components/spinner.md +109 -0
- package/assets/prompts/components/user-message.md +45 -0
- package/assets/prompts/primitives/scroll.md +22 -11
- package/assets/skill/SKILL.md +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import styles from "./MessageQuote.module.css";
|
|
5
|
+
|
|
6
|
+
export type MessageQuoteVariant = "plain" | "blockquote";
|
|
7
|
+
|
|
8
|
+
export interface MessageQuoteProps extends HTMLAttributes<HTMLQuoteElement> {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
/** plain — 그냥 글. blockquote — 왼쪽 vertical bar 리더. 기본 blockquote. */
|
|
11
|
+
variant?: MessageQuoteVariant;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function MessageQuote({
|
|
15
|
+
children,
|
|
16
|
+
variant = "blockquote",
|
|
17
|
+
className,
|
|
18
|
+
...rest
|
|
19
|
+
}: MessageQuoteProps) {
|
|
20
|
+
const cls = [
|
|
21
|
+
styles.root,
|
|
22
|
+
variant === "blockquote" ? styles.blockquote : styles.plain,
|
|
23
|
+
className,
|
|
24
|
+
]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join(" ");
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<blockquote className={cls} {...rest}>
|
|
30
|
+
{children}
|
|
31
|
+
</blockquote>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* MessageSources — AIMessage 하단 소스 chip 리스트. */
|
|
2
|
+
|
|
3
|
+
.root {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: var(--space-1);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.label {
|
|
10
|
+
font-size: var(--text-xs);
|
|
11
|
+
color: var(--fg-muted);
|
|
12
|
+
line-height: 1.2;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.chips {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-wrap: wrap;
|
|
18
|
+
gap: var(--space-1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.chip {
|
|
22
|
+
display: inline-flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
gap: var(--space-1);
|
|
25
|
+
padding: var(--space-1) var(--space-2);
|
|
26
|
+
background: var(--bg-subtle);
|
|
27
|
+
border-radius: var(--radius-full);
|
|
28
|
+
color: var(--fg);
|
|
29
|
+
font-size: var(--text-xs);
|
|
30
|
+
text-decoration: none;
|
|
31
|
+
transition: var(--transition-colors);
|
|
32
|
+
max-width: 100%;
|
|
33
|
+
min-width: 0;
|
|
34
|
+
}
|
|
35
|
+
.chip:hover {
|
|
36
|
+
background: var(--bg-muted);
|
|
37
|
+
color: var(--fg-strong);
|
|
38
|
+
}
|
|
39
|
+
.chip:focus-visible {
|
|
40
|
+
outline: none;
|
|
41
|
+
box-shadow: var(--ring);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.chipIcon {
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
justify-content: center;
|
|
48
|
+
width: var(--space-4);
|
|
49
|
+
height: var(--space-4);
|
|
50
|
+
flex: 0 0 auto;
|
|
51
|
+
color: var(--fg-muted);
|
|
52
|
+
}
|
|
53
|
+
.linkIcon {
|
|
54
|
+
width: 80%;
|
|
55
|
+
height: 80%;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.chipLabel {
|
|
59
|
+
overflow: hidden;
|
|
60
|
+
text-overflow: ellipsis;
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
min-width: 0;
|
|
63
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes } from "react";
|
|
4
|
+
import { Link as PhLink } from "@phosphor-icons/react";
|
|
5
|
+
import styles from "./MessageSources.module.css";
|
|
6
|
+
|
|
7
|
+
export interface MessageSource {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
href?: string;
|
|
11
|
+
/** 좌측 favicon 등 커스텀 아이콘. 없으면 기본 Link. */
|
|
12
|
+
icon?: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MessageSourcesProps extends HTMLAttributes<HTMLDivElement> {
|
|
16
|
+
sources: MessageSource[];
|
|
17
|
+
/** 상단 라벨. 기본 "Sources". */
|
|
18
|
+
label?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function MessageSources({
|
|
22
|
+
sources,
|
|
23
|
+
label = "Sources",
|
|
24
|
+
className,
|
|
25
|
+
...rest
|
|
26
|
+
}: MessageSourcesProps) {
|
|
27
|
+
const cls = [styles.root, className].filter(Boolean).join(" ");
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={cls} {...rest}>
|
|
31
|
+
<div className={styles.label}>{label}</div>
|
|
32
|
+
<div className={styles.chips}>
|
|
33
|
+
{sources.map((s) => (
|
|
34
|
+
<a
|
|
35
|
+
key={s.id}
|
|
36
|
+
className={styles.chip}
|
|
37
|
+
href={s.href || "#"}
|
|
38
|
+
target={s.href ? "_blank" : undefined}
|
|
39
|
+
rel={s.href ? "noopener noreferrer" : undefined}
|
|
40
|
+
>
|
|
41
|
+
<span className={styles.chipIcon}>
|
|
42
|
+
{s.icon ?? <PhLink className={styles.linkIcon} weight="regular" aria-hidden />}
|
|
43
|
+
</span>
|
|
44
|
+
<span className={styles.chipLabel}>{s.label}</span>
|
|
45
|
+
</a>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* MessageTable — markdown table 톤.
|
|
2
|
+
wrapper는 가로 스크롤 컨테이너, 안쪽 table이 트랙. */
|
|
3
|
+
|
|
4
|
+
.root {
|
|
5
|
+
width: 100%;
|
|
6
|
+
overflow-x: auto;
|
|
7
|
+
border: var(--hairline) solid var(--border);
|
|
8
|
+
border-radius: var(--radius-md);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.root :global(table) {
|
|
12
|
+
width: 100%;
|
|
13
|
+
border-collapse: collapse;
|
|
14
|
+
font-size: var(--text-sm);
|
|
15
|
+
color: var(--fg);
|
|
16
|
+
table-layout: auto;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.root :global(thead) {
|
|
20
|
+
background: var(--bg-subtle);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.root :global(th),
|
|
24
|
+
.root :global(td) {
|
|
25
|
+
padding: var(--space-2) var(--space-3);
|
|
26
|
+
text-align: left;
|
|
27
|
+
vertical-align: top;
|
|
28
|
+
border-bottom: var(--hairline) solid var(--border);
|
|
29
|
+
line-height: 1.5;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.root :global(th) {
|
|
33
|
+
color: var(--fg-strong);
|
|
34
|
+
font-weight: var(--font-weight-semibold);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* 숫자 셀 좌측 정렬 유지(피드백 no-right-align-numbers), 숫자는 tabular */
|
|
38
|
+
.root :global(td),
|
|
39
|
+
.root :global(th) {
|
|
40
|
+
font-variant-numeric: tabular-nums;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* 마지막 행 border 제거 */
|
|
44
|
+
.root :global(tr:last-child td) {
|
|
45
|
+
border-bottom: 0;
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import styles from "./MessageTable.module.css";
|
|
5
|
+
|
|
6
|
+
export interface MessageTableProps extends HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
/** children에 <table>...</table> 전달. CTDS가 톤만 입힘. */
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** AIMessage 안의 markdown table을 "AI 응답 톤"으로 감싸는 wrapper.
|
|
12
|
+
* 소비처는 <table><thead><tr><th>…</th></tr></thead><tbody>…</tbody></table> 그대로 전달.
|
|
13
|
+
* wrapper가 가로 스크롤·hairline·padding 처리. */
|
|
14
|
+
export function MessageTable({ children, className, ...rest }: MessageTableProps) {
|
|
15
|
+
const cls = [styles.root, className].filter(Boolean).join(" ");
|
|
16
|
+
return (
|
|
17
|
+
<div className={cls} {...rest}>
|
|
18
|
+
{children}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
gap: var(--space-2);
|
|
8
8
|
min-width: 0;
|
|
9
9
|
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
|
|
10
|
-
background: var(--bg);
|
|
11
|
-
border:
|
|
10
|
+
background: var(--bg-subtle);
|
|
11
|
+
border: 0;
|
|
12
12
|
border-radius: var(--radius-md);
|
|
13
13
|
color: var(--fg);
|
|
14
14
|
font-size: var(--text-sm);
|
|
@@ -137,76 +137,22 @@
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/* Profile ──────────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
IdentityButton의 preset 래퍼. 음수 margin으로 Footer padding-inline(--space-4) 상쇄 →
|
|
141
|
+
hover 영역이 사이드바 좌/우 끝까지 꽉 참. 안쪽 콘텐츠는 IdentityButton 자체 padding으로 복원.
|
|
142
|
+
radius 0은 "사이드바에 꽉 찬 바닥 블록" 느낌을 유지하기 위함. */
|
|
143
143
|
.profile {
|
|
144
|
-
display: flex;
|
|
145
|
-
align-items: center;
|
|
146
|
-
gap: var(--space-2);
|
|
147
144
|
margin-inline: calc(var(--space-4) * -1);
|
|
148
145
|
padding: var(--space-2) var(--space-4);
|
|
149
146
|
border-radius: 0;
|
|
150
|
-
min-width: 0;
|
|
151
147
|
width: auto;
|
|
152
|
-
cursor: pointer;
|
|
153
|
-
transition: var(--transition-colors);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
.profile:hover {
|
|
157
|
-
background: var(--hover);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
.profile:focus-visible {
|
|
161
|
-
outline: none;
|
|
162
|
-
background: var(--hover);
|
|
163
148
|
}
|
|
164
149
|
|
|
165
150
|
.profile[data-collapsed] {
|
|
166
|
-
justify-content: center;
|
|
167
|
-
gap: 0;
|
|
168
151
|
/* collapsed root padding(--space-2)에 맞춰 margin도 축소. */
|
|
169
152
|
margin-inline: calc(var(--space-2) * -1);
|
|
170
153
|
padding-inline: var(--space-2);
|
|
171
154
|
}
|
|
172
155
|
|
|
173
|
-
.profileBody {
|
|
174
|
-
display: flex;
|
|
175
|
-
flex-direction: column;
|
|
176
|
-
justify-content: center;
|
|
177
|
-
min-width: 0;
|
|
178
|
-
flex: 1 1 auto;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
.profileName {
|
|
182
|
-
font-family: var(--label-font);
|
|
183
|
-
font-size: var(--label-sm-size);
|
|
184
|
-
letter-spacing: var(--label-tracking);
|
|
185
|
-
line-height: var(--lh-xs);
|
|
186
|
-
font-weight: var(--font-weight-medium);
|
|
187
|
-
color: var(--fg);
|
|
188
|
-
white-space: nowrap;
|
|
189
|
-
overflow: hidden;
|
|
190
|
-
text-overflow: ellipsis;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
.profileSub {
|
|
194
|
-
font-family: var(--label-font);
|
|
195
|
-
font-size: var(--text-xs);
|
|
196
|
-
line-height: var(--lh-xs);
|
|
197
|
-
font-weight: var(--font-weight-regular);
|
|
198
|
-
color: var(--fg-muted);
|
|
199
|
-
white-space: nowrap;
|
|
200
|
-
overflow: hidden;
|
|
201
|
-
text-overflow: ellipsis;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
.profileTrailing {
|
|
205
|
-
display: inline-flex;
|
|
206
|
-
align-items: center;
|
|
207
|
-
flex: 0 0 auto;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
156
|
/* Section ────────────────────────────────────────────
|
|
211
157
|
스크롤 트랙(.body) 안쪽의 "items" 레이어. 좌우 padding은 여기가 담당.
|
|
212
158
|
.body 자체에 두면 스크롤바가 padding 안쪽으로 밀려 좌/우 끝에 안 붙음. */
|
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { type AvatarProps } from "../Avatar";
|
|
12
|
+
import { IdentityButton } from "../IdentityButton";
|
|
12
13
|
import { MenuItem, type MenuItemAction } from "../MenuItem";
|
|
13
14
|
import styles from "./Sidebar.module.css";
|
|
14
15
|
|
|
@@ -98,16 +99,17 @@ function SidebarFooter({ children, className, ...rest }: HTMLAttributes<HTMLDivE
|
|
|
98
99
|
|
|
99
100
|
/* Profile ──────────────────────────────────────────────
|
|
100
101
|
사이드바 하단 사용자 식별 영역.
|
|
101
|
-
Avatar
|
|
102
|
-
collapsed일 땐 Avatar만 노출(aria-label로 접근성 유지).
|
|
102
|
+
[[components/identity-button]]의 preset 래퍼 — Avatar + 2줄 라벨 + trailing 뼈대 공유.
|
|
103
103
|
사이즈·간격·폰트는 MenuItem md 리듬에 맞춰 통일 — 인라인 스타일로 조립 금지. */
|
|
104
|
-
export interface SidebarProfileProps
|
|
104
|
+
export interface SidebarProfileProps {
|
|
105
105
|
name: string;
|
|
106
106
|
sub?: ReactNode;
|
|
107
107
|
/** Avatar에 그대로 패스스루. src/initials/icon/status 등 자유롭게. */
|
|
108
108
|
avatar?: Omit<AvatarProps, "size" | "name" | "alt">;
|
|
109
109
|
/** 오른쪽 끝 보조 UI(IconButton 등). collapsed일 땐 숨김. */
|
|
110
110
|
trailing?: ReactNode;
|
|
111
|
+
className?: string;
|
|
112
|
+
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
function SidebarProfile({
|
|
@@ -116,27 +118,22 @@ function SidebarProfile({
|
|
|
116
118
|
avatar,
|
|
117
119
|
trailing,
|
|
118
120
|
className,
|
|
119
|
-
|
|
121
|
+
onClick,
|
|
120
122
|
}: SidebarProfileProps) {
|
|
121
123
|
const { collapsed } = useSidebar();
|
|
122
124
|
return (
|
|
123
|
-
<
|
|
125
|
+
<IdentityButton
|
|
126
|
+
size="md"
|
|
127
|
+
variant="ghost"
|
|
128
|
+
avatar={avatar}
|
|
129
|
+
name={name}
|
|
130
|
+
sub={sub}
|
|
131
|
+
trailing={trailing}
|
|
132
|
+
collapsed={collapsed}
|
|
124
133
|
className={[styles.profile, className ?? ""].filter(Boolean).join(" ")}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
>
|
|
129
|
-
<Avatar size="md" name={name} alt={name} {...avatar} />
|
|
130
|
-
{!collapsed && (
|
|
131
|
-
<div className={styles.profileBody}>
|
|
132
|
-
<span className={styles.profileName}>{name}</span>
|
|
133
|
-
{sub && <span className={styles.profileSub}>{sub}</span>}
|
|
134
|
-
</div>
|
|
135
|
-
)}
|
|
136
|
-
{!collapsed && trailing && (
|
|
137
|
-
<div className={styles.profileTrailing}>{trailing}</div>
|
|
138
|
-
)}
|
|
139
|
-
</div>
|
|
134
|
+
aria-label={collapsed ? name : undefined}
|
|
135
|
+
onClick={onClick}
|
|
136
|
+
/>
|
|
140
137
|
);
|
|
141
138
|
}
|
|
142
139
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LottiePlayer — Spinner가 lottie-* variant일 때 내부에서 쓰는 플레이어.
|
|
5
|
+
*
|
|
6
|
+
* - 서버 사이드에서는 아무것도 렌더 안 함 (lottie-react는 client-only).
|
|
7
|
+
* - fetch로 /lottie/<name>.json 로드 → 캐시 (모듈 레벨).
|
|
8
|
+
* - 매니페스트의 colorBind=true면 바깥 요소 computed color로 recolor.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
|
12
|
+
import { Lottie } from "lottie-react";
|
|
13
|
+
import { LOTTIE_MANIFEST, type LottieName } from "./lottieManifest";
|
|
14
|
+
import { parseCssColor, recolorLottie } from "./lottieColor";
|
|
15
|
+
import styles from "./Spinner.module.css";
|
|
16
|
+
|
|
17
|
+
/** JSON 캐시 — 같은 로티를 여러 자리에서 쓸 때 fetch 1회만. */
|
|
18
|
+
const cache = new Map<LottieName, unknown>();
|
|
19
|
+
const inflight = new Map<LottieName, Promise<unknown>>();
|
|
20
|
+
|
|
21
|
+
async function loadLottie(name: LottieName): Promise<unknown> {
|
|
22
|
+
if (cache.has(name)) return cache.get(name);
|
|
23
|
+
if (inflight.has(name)) return inflight.get(name)!;
|
|
24
|
+
const p = fetch(LOTTIE_MANIFEST[name].path)
|
|
25
|
+
.then((r) => r.json())
|
|
26
|
+
.then((json) => {
|
|
27
|
+
cache.set(name, json);
|
|
28
|
+
inflight.delete(name);
|
|
29
|
+
return json;
|
|
30
|
+
});
|
|
31
|
+
inflight.set(name, p);
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type LottiePlayerProps = {
|
|
36
|
+
name: LottieName;
|
|
37
|
+
className?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function LottiePlayer({ name, className }: LottiePlayerProps) {
|
|
41
|
+
const entry = LOTTIE_MANIFEST[name];
|
|
42
|
+
const [data, setData] = useState<unknown>(null);
|
|
43
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
loadLottie(name).then((raw) => {
|
|
48
|
+
if (cancelled) return;
|
|
49
|
+
if (!entry.colorBind) {
|
|
50
|
+
setData(raw);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// 바깥 요소의 computed color를 읽어 재색상화
|
|
54
|
+
const el = containerRef.current;
|
|
55
|
+
const css = el ? getComputedStyle(el).color : "rgb(0,0,0)";
|
|
56
|
+
const rgb = parseCssColor(css) ?? [0, 0, 0];
|
|
57
|
+
setData(recolorLottie(raw, rgb));
|
|
58
|
+
});
|
|
59
|
+
return () => {
|
|
60
|
+
cancelled = true;
|
|
61
|
+
};
|
|
62
|
+
}, [name, entry.colorBind]);
|
|
63
|
+
|
|
64
|
+
const scaleStyle = entry.visualScale
|
|
65
|
+
? ({ "--lottie-scale": entry.visualScale } as CSSProperties)
|
|
66
|
+
: undefined;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div ref={containerRef} className={className} style={scaleStyle}>
|
|
70
|
+
{data ? (
|
|
71
|
+
<Lottie src={data} loop autoplay className={styles.lottieFill} />
|
|
72
|
+
) : null}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/* Spinner — 공통 바깥 박스
|
|
2
|
+
variant 지정 → data-variant로 분기
|
|
3
|
+
variant 미지정 → [data-motion="…"] 조상 셀렉터로 자동 매핑 (Motion 축 추종)
|
|
4
|
+
|
|
5
|
+
색은 currentColor로 묶여 부모 color를 그대로 승계 (Palette/Hue 축 호환). */
|
|
6
|
+
|
|
7
|
+
.root {
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
position: relative;
|
|
12
|
+
color: currentColor;
|
|
13
|
+
width: var(--loader-size, var(--loader-size-sm));
|
|
14
|
+
height: var(--loader-size, var(--loader-size-sm));
|
|
15
|
+
flex-shrink: 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.xs { --loader-size: var(--loader-size-xs); }
|
|
19
|
+
.sm { --loader-size: var(--loader-size-sm); }
|
|
20
|
+
.md { --loader-size: var(--loader-size-md); }
|
|
21
|
+
.lg { --loader-size: var(--loader-size-lg); }
|
|
22
|
+
|
|
23
|
+
/* 자식 전부 기본 숨김 — 활성 variant만 켬. */
|
|
24
|
+
.ring,
|
|
25
|
+
.dot,
|
|
26
|
+
.pulse {
|
|
27
|
+
display: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* ─────────────────────────────────────────────────────────
|
|
31
|
+
공통 variant 규칙 (키: ring / dots-bounce / dots-fade / pulse)
|
|
32
|
+
아래는 "활성 variant → 어떤 자식을 어떻게 보이게 할지"만 정의.
|
|
33
|
+
무엇이 활성 variant인가는 더 아래 "매칭" 섹션이 결정.
|
|
34
|
+
───────────────────────────────────────────────────────── */
|
|
35
|
+
|
|
36
|
+
/* ring */
|
|
37
|
+
.v-ring .ring {
|
|
38
|
+
display: block;
|
|
39
|
+
width: 100%;
|
|
40
|
+
height: 100%;
|
|
41
|
+
animation: m-spin calc(var(--duration-slower) * 2) linear infinite; /* lint-hardcoded: ignore */
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* dots 공통 */
|
|
45
|
+
.v-dots {
|
|
46
|
+
width: auto;
|
|
47
|
+
min-width: var(--loader-size);
|
|
48
|
+
gap: calc(var(--loader-size) * 0.22);
|
|
49
|
+
}
|
|
50
|
+
.v-dots .dot {
|
|
51
|
+
display: block;
|
|
52
|
+
width: calc(var(--loader-size) * 0.32);
|
|
53
|
+
height: calc(var(--loader-size) * 0.32);
|
|
54
|
+
border-radius: 50%;
|
|
55
|
+
background: currentColor;
|
|
56
|
+
flex-shrink: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* dots-bounce */
|
|
60
|
+
.v-dots-bounce .dot {
|
|
61
|
+
animation: m-dots-bounce calc(var(--duration-slower) * 1.5) var(--ease-in-out) infinite;
|
|
62
|
+
}
|
|
63
|
+
.v-dots-bounce .dot:nth-child(3) {
|
|
64
|
+
animation-delay: calc(var(--duration-base) * -1);
|
|
65
|
+
}
|
|
66
|
+
.v-dots-bounce .dot:nth-child(4) {
|
|
67
|
+
animation-delay: calc(var(--duration-slow) * -1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* dots-fade */
|
|
71
|
+
.v-dots-fade .dot {
|
|
72
|
+
animation: m-dots-fade calc(var(--duration-slower) * 1.5) var(--ease-in-out) infinite;
|
|
73
|
+
}
|
|
74
|
+
.v-dots-fade .dot:nth-child(3) {
|
|
75
|
+
animation-delay: calc(var(--duration-base) * -1);
|
|
76
|
+
}
|
|
77
|
+
.v-dots-fade .dot:nth-child(4) {
|
|
78
|
+
animation-delay: calc(var(--duration-slow) * -1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* pulse */
|
|
82
|
+
.v-pulse .pulse {
|
|
83
|
+
display: block;
|
|
84
|
+
width: 100%;
|
|
85
|
+
height: 100%;
|
|
86
|
+
border-radius: 50%;
|
|
87
|
+
background: currentColor;
|
|
88
|
+
opacity: 0.6;
|
|
89
|
+
animation: m-pulse calc(var(--duration-slower) * 2) var(--ease-in-out) infinite;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ─────────────────────────────────────────────────────────
|
|
93
|
+
Lottie variants — LottiePlayer가 바깥 박스 100% 채움.
|
|
94
|
+
bounds는 .root가 결정 (size prop), aspect=wide 로티는 .wide로 폭을 2배.
|
|
95
|
+
───────────────────────────────────────────────────────── */
|
|
96
|
+
.v-lottie {
|
|
97
|
+
overflow: visible;
|
|
98
|
+
}
|
|
99
|
+
.v-lottie.wide {
|
|
100
|
+
width: calc(var(--loader-size) * 2);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.lottieFill {
|
|
104
|
+
width: 100%;
|
|
105
|
+
height: 100%;
|
|
106
|
+
/* 로티 원본이 캔버스를 덜 채워 체감 크기가 작은 경우, 매니페스트의 visualScale이
|
|
107
|
+
--lottie-scale을 지정 → bounds 안에서 scale-up. 지정 없으면 1 (원본). */
|
|
108
|
+
transform: scale(var(--lottie-scale, 1));
|
|
109
|
+
transform-origin: center;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ─────────────────────────────────────────────────────────
|
|
113
|
+
v-auto — variant 미지정 시 Motion 축을 따라감.
|
|
114
|
+
조상 data-motion 값에 따라 분기. 기본(미지정)은 ring.
|
|
115
|
+
───────────────────────────────────────────────────────── */
|
|
116
|
+
|
|
117
|
+
.v-auto .ring {
|
|
118
|
+
display: block;
|
|
119
|
+
width: 100%;
|
|
120
|
+
height: 100%;
|
|
121
|
+
animation: m-spin calc(var(--duration-slower) * 2) linear infinite; /* lint-hardcoded: ignore */
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
[data-motion="bouncy"] .v-auto .ring,
|
|
125
|
+
[data-motion="calm"] .v-auto .ring {
|
|
126
|
+
display: none;
|
|
127
|
+
animation: none;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
[data-motion="bouncy"] .v-auto,
|
|
131
|
+
[data-motion="calm"] .v-auto {
|
|
132
|
+
width: auto;
|
|
133
|
+
min-width: var(--loader-size);
|
|
134
|
+
gap: calc(var(--loader-size) * 0.22);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
[data-motion="bouncy"] .v-auto .dot,
|
|
138
|
+
[data-motion="calm"] .v-auto .dot {
|
|
139
|
+
display: block;
|
|
140
|
+
width: calc(var(--loader-size) * 0.32);
|
|
141
|
+
height: calc(var(--loader-size) * 0.32);
|
|
142
|
+
border-radius: 50%;
|
|
143
|
+
background: currentColor;
|
|
144
|
+
flex-shrink: 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* bouncy → dots-bounce */
|
|
148
|
+
[data-motion="bouncy"] .v-auto .dot {
|
|
149
|
+
animation: m-dots-bounce calc(var(--duration-slower) * 1.5) var(--ease-in-out) infinite;
|
|
150
|
+
}
|
|
151
|
+
[data-motion="bouncy"] .v-auto .dot:nth-child(3) {
|
|
152
|
+
animation-delay: calc(var(--duration-base) * -1);
|
|
153
|
+
}
|
|
154
|
+
[data-motion="bouncy"] .v-auto .dot:nth-child(4) {
|
|
155
|
+
animation-delay: calc(var(--duration-slow) * -1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* calm → dots-fade */
|
|
159
|
+
[data-motion="calm"] .v-auto .dot {
|
|
160
|
+
animation: m-dots-fade calc(var(--duration-slower) * 1.5) var(--ease-in-out) infinite;
|
|
161
|
+
}
|
|
162
|
+
[data-motion="calm"] .v-auto .dot:nth-child(3) {
|
|
163
|
+
animation-delay: calc(var(--duration-base) * -1);
|
|
164
|
+
}
|
|
165
|
+
[data-motion="calm"] .v-auto .dot:nth-child(4) {
|
|
166
|
+
animation-delay: calc(var(--duration-slow) * -1);
|
|
167
|
+
}
|