@conscience-technology/ctds 0.1.9 → 0.1.11
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 +129 -0
- package/assets/components/AIMessage/AIMessage.tsx +104 -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 +82 -45
- package/assets/components/ChatInput/ChatInput.tsx +46 -34
- 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 +29 -0
- package/assets/components/MessageQuote/MessageQuote.tsx +33 -0
- package/assets/components/MessageQuote/index.ts +2 -0
- package/assets/components/MessageSources/MessageSources.module.css +58 -0
- package/assets/components/MessageSources/MessageSources.tsx +64 -0
- package/assets/components/MessageSources/index.ts +2 -0
- package/assets/components/MessageTable/MessageTable.module.css +59 -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 +82 -0
- package/assets/components/Spinner/Spinner.module.css +170 -0
- package/assets/components/Spinner/Spinner.tsx +117 -0
- package/assets/components/Spinner/index.ts +10 -0
- package/assets/components/Spinner/lottieColor.ts +106 -0
- package/assets/components/Spinner/lottieManifest.ts +100 -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 +45 -0
- package/assets/prompts/components/bubble.md +56 -0
- package/assets/prompts/components/calendar.md +116 -0
- package/assets/prompts/components/code-block.md +35 -0
- package/assets/prompts/components/dropdown-menu.md +145 -0
- package/assets/prompts/components/event-chip.md +74 -0
- package/assets/prompts/components/identity-button.md +168 -0
- 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 +33 -0
- package/assets/prompts/components/message-table.md +27 -0
- package/assets/prompts/components/sidebar.md +48 -3
- 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/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* MessageQuote — AIMessage 안 인용 블록. plain(그냥 글) / blockquote(왼쪽 bar 리더). */
|
|
2
|
+
|
|
3
|
+
.root {
|
|
4
|
+
margin: 0;
|
|
5
|
+
color: var(--fg);
|
|
6
|
+
font-size: var(--text-base);
|
|
7
|
+
line-height: 2;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.plain {
|
|
11
|
+
/* 그냥 글 — bar 없음 */
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.blockquote {
|
|
15
|
+
position: relative;
|
|
16
|
+
padding-left: var(--space-4);
|
|
17
|
+
color: var(--fg-muted);
|
|
18
|
+
}
|
|
19
|
+
/* bar를 pseudo로 — 상·하에서 살짝 들어가 짧은 느낌 */
|
|
20
|
+
.blockquote::before {
|
|
21
|
+
content: "";
|
|
22
|
+
position: absolute;
|
|
23
|
+
left: 0;
|
|
24
|
+
top: var(--space-1);
|
|
25
|
+
bottom: var(--space-1);
|
|
26
|
+
width: var(--space-0_5);
|
|
27
|
+
background: var(--border-strong);
|
|
28
|
+
border-radius: var(--radius-full);
|
|
29
|
+
}
|
|
@@ -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,58 @@
|
|
|
1
|
+
/* MessageSources — AIMessage 액션바 inline chip.
|
|
2
|
+
[□□□ 겹친 favicon] Sources +N 형태. 작고 가벼운 chip. */
|
|
3
|
+
|
|
4
|
+
.root {
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: var(--space-1);
|
|
8
|
+
padding: var(--space-0_5) var(--space-2) var(--space-0_5) var(--space-1);
|
|
9
|
+
border: 0;
|
|
10
|
+
background: transparent;
|
|
11
|
+
color: var(--fg-muted);
|
|
12
|
+
border-radius: var(--radius-full);
|
|
13
|
+
cursor: pointer;
|
|
14
|
+
font-size: var(--text-xs);
|
|
15
|
+
line-height: 1;
|
|
16
|
+
transition: var(--transition-colors);
|
|
17
|
+
}
|
|
18
|
+
.root:hover {
|
|
19
|
+
background: var(--bg-subtle);
|
|
20
|
+
color: var(--fg-strong);
|
|
21
|
+
}
|
|
22
|
+
.root:focus-visible {
|
|
23
|
+
outline: none;
|
|
24
|
+
box-shadow: var(--ring);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* favicon stack — 서로 살짝 겹침 (negative margin).
|
|
28
|
+
각 favicon은 box-shadow로 bg와 같은 cutout 링을 둘러 겹침 경계를 명확히. */
|
|
29
|
+
.stack {
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
}
|
|
33
|
+
.favicon {
|
|
34
|
+
display: inline-flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: center;
|
|
37
|
+
width: var(--space-4);
|
|
38
|
+
height: var(--space-4);
|
|
39
|
+
border-radius: var(--radius-full);
|
|
40
|
+
background: var(--bg-muted);
|
|
41
|
+
box-shadow: 0 0 0 var(--hairline) var(--bg);
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
margin-left: calc(var(--space-1) * -1);
|
|
44
|
+
flex: 0 0 auto;
|
|
45
|
+
}
|
|
46
|
+
.favicon:first-child {
|
|
47
|
+
margin-left: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.label {
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: baseline;
|
|
53
|
+
gap: var(--space-0_5);
|
|
54
|
+
}
|
|
55
|
+
.extra {
|
|
56
|
+
color: var(--fg-muted);
|
|
57
|
+
font-variant-numeric: tabular-nums;
|
|
58
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes, MouseEvent, ReactNode } from "react";
|
|
4
|
+
import styles from "./MessageSources.module.css";
|
|
5
|
+
|
|
6
|
+
export interface MessageSource {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
href?: string;
|
|
10
|
+
/** 왼쪽 favicon. 없으면 중성 원형 placeholder. */
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MessageSourcesProps extends HTMLAttributes<HTMLButtonElement> {
|
|
15
|
+
sources: MessageSource[];
|
|
16
|
+
/** 라벨 override. 기본 "Sources". */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** 겹쳐 보여줄 favicon 최대 개수. 기본 3. */
|
|
19
|
+
maxFavicons?: number;
|
|
20
|
+
/** chip 클릭 시 (popover 열기 등). 외부에서 처리. */
|
|
21
|
+
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** AIMessage의 MessageActions 바 옆에 inline으로 들어가는 작은 chip.
|
|
25
|
+
* [겹친 favicon stack] + 라벨 (+N) 형태. 클릭 시 popover 등은 소비처가 조립. */
|
|
26
|
+
export function MessageSources({
|
|
27
|
+
sources,
|
|
28
|
+
label = "Sources",
|
|
29
|
+
maxFavicons = 3,
|
|
30
|
+
onClick,
|
|
31
|
+
className,
|
|
32
|
+
...rest
|
|
33
|
+
}: MessageSourcesProps) {
|
|
34
|
+
const visible = sources.slice(0, maxFavicons);
|
|
35
|
+
const extra = sources.length - visible.length;
|
|
36
|
+
|
|
37
|
+
const cls = [styles.root, className].filter(Boolean).join(" ");
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
className={cls}
|
|
43
|
+
onClick={onClick}
|
|
44
|
+
aria-label={`${label} (${sources.length})`}
|
|
45
|
+
{...rest}
|
|
46
|
+
>
|
|
47
|
+
<span className={styles.stack} aria-hidden>
|
|
48
|
+
{visible.map((s, i) => (
|
|
49
|
+
<span
|
|
50
|
+
key={s.id}
|
|
51
|
+
className={styles.favicon}
|
|
52
|
+
style={{ ["--stack-i" as string]: String(i) }}
|
|
53
|
+
>
|
|
54
|
+
{s.icon ?? null}
|
|
55
|
+
</span>
|
|
56
|
+
))}
|
|
57
|
+
</span>
|
|
58
|
+
<span className={styles.label}>
|
|
59
|
+
{label}
|
|
60
|
+
{extra > 0 ? <span className={styles.extra}> +{extra}</span> : null}
|
|
61
|
+
</span>
|
|
62
|
+
</button>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* MessageTable — AIMessage 안 markdown table. TableHeader/TableRow의 line variant 톤에 맞춤:
|
|
2
|
+
외곽 border 없음, header도 bg 없이 하단 hairline만. row도 하단 hairline만.
|
|
3
|
+
wrapper는 가로 overflow 시 scroll. */
|
|
4
|
+
|
|
5
|
+
.root {
|
|
6
|
+
width: 100%;
|
|
7
|
+
overflow-x: auto;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.root :global(table) {
|
|
11
|
+
width: 100%;
|
|
12
|
+
border-collapse: collapse;
|
|
13
|
+
font-size: var(--text-sm);
|
|
14
|
+
color: var(--fg);
|
|
15
|
+
table-layout: auto;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* header — bg 투명, 하단 hairline만 */
|
|
19
|
+
.root :global(thead tr) {
|
|
20
|
+
border-bottom: var(--hairline) solid var(--border);
|
|
21
|
+
}
|
|
22
|
+
.root :global(th) {
|
|
23
|
+
color: var(--fg-strong);
|
|
24
|
+
font-weight: var(--font-weight-medium);
|
|
25
|
+
text-align: left;
|
|
26
|
+
padding: var(--space-2) var(--space-2);
|
|
27
|
+
line-height: var(--label-sm-line-height);
|
|
28
|
+
font-size: var(--label-sm-size);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* 첫 셀 좌측 padding 0, 마지막 셀 우측 padding 0 — TableHeader.line과 짝 */
|
|
32
|
+
.root :global(thead th:first-child),
|
|
33
|
+
.root :global(tbody td:first-child) {
|
|
34
|
+
padding-left: 0;
|
|
35
|
+
}
|
|
36
|
+
.root :global(thead th:last-child),
|
|
37
|
+
.root :global(tbody td:last-child) {
|
|
38
|
+
padding-right: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* row — 하단 hairline만, 배경 투명 */
|
|
42
|
+
.root :global(tbody tr) {
|
|
43
|
+
border-bottom: var(--hairline) solid var(--border);
|
|
44
|
+
}
|
|
45
|
+
.root :global(tbody tr:last-child) {
|
|
46
|
+
border-bottom: 0;
|
|
47
|
+
}
|
|
48
|
+
.root :global(td) {
|
|
49
|
+
padding: var(--space-2) var(--space-2);
|
|
50
|
+
text-align: left;
|
|
51
|
+
vertical-align: top;
|
|
52
|
+
line-height: 1.5;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* 숫자 셀도 좌측 정렬 유지 (feedback-no-right-align-numbers), 숫자 폭 균일 */
|
|
56
|
+
.root :global(td),
|
|
57
|
+
.root :global(th) {
|
|
58
|
+
font-variant-numeric: tabular-nums;
|
|
59
|
+
}
|
|
@@ -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,82 @@
|
|
|
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
|
+
// offset은 bounds(--loader-size) 비율 기준. 스케일된 자기 요소 % 혼동을 피하려고 calc로 묶음.
|
|
65
|
+
const style = {
|
|
66
|
+
...(entry.visualScale ? { "--lottie-scale": entry.visualScale } : {}),
|
|
67
|
+
...(entry.offsetX
|
|
68
|
+
? { "--lottie-offset-x": `calc(var(--loader-size) * ${entry.offsetX})` }
|
|
69
|
+
: {}),
|
|
70
|
+
...(entry.offsetY
|
|
71
|
+
? { "--lottie-offset-y": `calc(var(--loader-size) * ${entry.offsetY})` }
|
|
72
|
+
: {}),
|
|
73
|
+
} as CSSProperties;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div ref={containerRef} className={className} style={style}>
|
|
77
|
+
{data ? (
|
|
78
|
+
<Lottie src={data} loop autoplay className={styles.lottieFill} />
|
|
79
|
+
) : null}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|